Compare commits
6 Commits
b9be88ae3f
...
c4447b91dd
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c4447b91dd | ||
|
|
39a9bd83f1 | ||
|
|
de48bd2324 | ||
|
|
5b8bd2797a | ||
|
|
fd5205e33e | ||
|
|
1d0b4c9fbb |
2
jeecg-boot/.gitignore
vendored
2
jeecg-boot/.gitignore
vendored
@@ -12,6 +12,8 @@ rebel.xml
|
||||
## backend
|
||||
**/target
|
||||
**/logs
|
||||
# 开发者本机钉钉 Stream 接收配置(从 application-dev-local.yml.example 复制)
|
||||
**/application-dev-local.yml
|
||||
|
||||
## front
|
||||
**/*.lock
|
||||
|
||||
@@ -709,6 +709,248 @@ jeecg-boot/jeecg-module-system/jeecg-system-biz/src/main/java/org/jeecg/modules/
|
||||
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
|
||||
|
||||
-- author:GHT---date:20260608--for: 【审批注册中心】明细表查看钉钉审批流转记录时间轴 -----
|
||||
jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/integration/entity/MesXslApprovalTrace.java
|
||||
jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/integration/vo/DingOperationRecordVO.java
|
||||
jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/integration/vo/DingProcessInstanceFlowVO.java
|
||||
jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/integration/service/IMesXslApprovalTraceService.java
|
||||
jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/integration/service/impl/MesXslApprovalTraceServiceImpl.java
|
||||
jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/integration/controller/MesXslApprovalTraceController.java
|
||||
jeecgboot-vue3/src/views/xslmes/approval/integration/MesXslApprovalTrace.api.ts
|
||||
jeecgboot-vue3/src/views/xslmes/approval/integration/MesXslApprovalTrace.data.ts
|
||||
jeecgboot-vue3/src/views/xslmes/approval/integration/components/MesXslApprovalTraceDrawer.vue
|
||||
jeecgboot-vue3/src/views/xslmes/approval/integration/components/DingApprovalFlowTimelineModal.vue
|
||||
|
||||
-- author:GHT---date:20260608--for: 【审批注册中心】钉钉操作人ID映射本地用户姓名 -----
|
||||
jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/integration/vo/DingOperationRecordVO.java
|
||||
jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/integration/service/impl/MesXslApprovalTraceServiceImpl.java
|
||||
jeecgboot-vue3/src/views/xslmes/approval/integration/components/DingApprovalFlowTimelineModal.vue
|
||||
|
||||
-- author:GHT---date:20260608--for: 【审批注册中心】流转记录列新增查看审批节点(processForecast) -----
|
||||
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/integration/vo/DingProcessForecastVO.java
|
||||
jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/integration/vo/DingProcessForecastNodeVO.java
|
||||
jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/integration/service/IMesXslApprovalTraceService.java
|
||||
jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/integration/service/impl/MesXslApprovalTraceServiceImpl.java
|
||||
jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/integration/controller/MesXslApprovalTraceController.java
|
||||
jeecgboot-vue3/src/views/xslmes/approval/integration/MesXslApprovalTrace.api.ts
|
||||
jeecgboot-vue3/src/views/xslmes/approval/integration/MesXslApprovalTrace.data.ts
|
||||
jeecgboot-vue3/src/views/xslmes/approval/integration/components/MesXslApprovalTraceDrawer.vue
|
||||
jeecgboot-vue3/src/views/xslmes/approval/integration/components/DingApprovalForecastModal.vue
|
||||
|
||||
-- author:GHT---date:20260608--for: 【审批注册中心】processForecast携带MES发起approvers还原审批节点 -----
|
||||
jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/dingtalk/service/DingApprovalLaunchParamBuilder.java
|
||||
jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/integration/vo/DingProcessForecastVO.java
|
||||
jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/integration/vo/DingProcessForecastNodeVO.java
|
||||
jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/integration/service/impl/MesXslApprovalTraceServiceImpl.java
|
||||
jeecgboot-vue3/src/views/xslmes/approval/integration/components/DingApprovalForecastModal.vue
|
||||
jeecgboot-vue3/src/views/xslmes/approval/integration/components/MesXslApprovalTraceDrawer.vue
|
||||
jeecgboot-vue3/src/views/xslmes/approval/integration/MesXslApprovalTrace.data.ts
|
||||
|
||||
-- author:GHT---date:20260609--for: 【钉钉Stream开发】第三方配置页可视化Stream接收节点 -----
|
||||
jeecg-boot/jeecg-module-system/jeecg-system-start/src/main/resources/flyway/sql/mysql/V3.9.2_145__sys_third_app_config_stream_node.sql
|
||||
jeecg-boot/jeecg-module-system/jeecg-system-biz/src/main/java/org/jeecg/modules/system/entity/SysThirdAppConfig.java
|
||||
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/DingTalkStreamNodeConfigService.java
|
||||
jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/dingtalk/controller/DingTalkStreamConfigController.java
|
||||
jeecgboot-vue3/src/views/system/appconfig/ThirdApp.data.ts
|
||||
jeecgboot-vue3/src/views/system/appconfig/ThirdApp.api.ts
|
||||
jeecgboot-vue3/src/views/system/appconfig/ThirdAppConfigModal.vue
|
||||
jeecgboot-vue3/src/views/system/appconfig/ThirdAppDingTalkConfigForm.vue
|
||||
|
||||
-- author:GHT---date:20260609--for: 【钉钉Stream开发】本机白名单仅指定电脑接收回调 -----
|
||||
jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/dingtalk/stream/DingTalkStreamProperties.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/DingApprovalReconcileScheduler.java
|
||||
jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/dingtalk/stream/DingTalkStreamHealthMonitor.java
|
||||
jeecg-boot/jeecg-module-system/jeecg-system-start/src/main/resources/application-dev.yml
|
||||
jeecg-boot/jeecg-module-system/jeecg-system-start/src/main/resources/application-dev-local.yml.example
|
||||
jeecg-boot/.gitignore
|
||||
|
||||
-- author:GHT---date:20260609--for: 【钉钉Stream集群】Redis选主单节点建连+存活监控 -----
|
||||
jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/dingtalk/stream/DingTalkStreamProperties.java
|
||||
jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/dingtalk/stream/DingTalkStreamLeaderElection.java
|
||||
jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/dingtalk/stream/DingTalkStreamHealthMonitor.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/DingTalkStreamSdkRunner.java
|
||||
jeecg-boot/jeecg-module-system/jeecg-system-start/src/main/resources/application-dev.yml
|
||||
|
||||
-- author:cursor---date:20260608--for: 【XSLMES-20260608-A01】混炼示方新增状态字段及列表查询条件 -----
|
||||
jeecg-boot/jeecg-module-system/jeecg-system-start/src/main/resources/flyway/sql/mysql/V3.9.2_141__mes_xsl_mixing_spec_status.sql
|
||||
jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/entity/MesXslMixingSpec.java
|
||||
jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/service/impl/MesXslMixingSpecServiceImpl.java
|
||||
jeecgboot-vue3/src/views/xslmes/mesXslMixingSpec/MesXslMixingSpec.data.ts
|
||||
jeecgboot-vue3/src/views/xslmes/approval/integration/components/VisualActionEditor.vue
|
||||
|
||||
-- author:GHT---date:20260610--for: 【MESToDing审批配置】钉钉模板列表操作列绑定审批流程弹窗 -----
|
||||
jeecgboot-vue3/src/views/xslmes/dingtalk/mesXslDingProcessTpl/components/BindApprovalFlowModal.vue
|
||||
jeecgboot-vue3/src/views/xslmes/dingtalk/mesXslDingProcessTpl/MesXslDingProcessTplList.vue
|
||||
|
||||
-- author:GHT---date:20260610--for: 【MESToDing审批配置】模板名称 MES↔钉钉 双向同步 -----
|
||||
jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/dingtalk/controller/MesXslDingProcessTplController.java
|
||||
jeecgboot-vue3/src/views/xslmes/dingtalk/mesXslDingProcessTpl/MesXslDingProcessTpl.api.ts
|
||||
jeecgboot-vue3/src/views/xslmes/dingtalk/mesXslDingProcessTpl/components/DingTplDesigner.vue
|
||||
|
||||
-- author:GHT---date:20260610--for: 【钉钉审批模板绑定】字段绑定支持原值/显示文本 -----
|
||||
jeecg-boot/jeecg-module-system/jeecg-system-biz/src/main/java/org/jeecg/modules/print/vo/PrintBizFieldItemVO.java
|
||||
jeecg-boot/jeecg-module-system/jeecg-system-biz/src/main/java/org/jeecg/modules/print/util/PrintBizEntityFieldIntrospector.java
|
||||
jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/dingtalk/service/DingTplBindFieldValueResolver.java
|
||||
jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/dingtalk/controller/MesXslDingTplBindController.java
|
||||
jeecgboot-vue3/src/views/xslmes/dingtalk/dingTplBind/dingTplFieldValue.ts
|
||||
jeecgboot-vue3/src/views/xslmes/dingtalk/dingTplBind/dingTplBind.api.ts
|
||||
jeecgboot-vue3/src/views/xslmes/dingtalk/dingTplBind/index.vue
|
||||
jeecgboot-vue3/src/components/DingTplLaunch/DingBindLaunchModal.vue
|
||||
|
||||
-- author:GHT---date:20260610--for: 【审批流设计】节点内生成集成方案并配置动作 -----
|
||||
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/MesXslIntegrationPlan.api.ts
|
||||
jeecgboot-vue3/src/views/xslmes/approval/integration/components/MesXslIntegrationActionDrawer.vue
|
||||
jeecgboot-vue3/src/views/approval/flow/components/FlowDesign.vue
|
||||
jeecgboot-vue3/src/views/approval/flow/components/NodeConfigDrawer.vue
|
||||
|
||||
-- author:GHT---date:20260610--for: 【审批注册中心】物理表名改为数据库表下拉选择 -----
|
||||
jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/integration/controller/MesXslBizDocRegistryController.java
|
||||
jeecgboot-vue3/src/views/xslmes/approval/integration/MesXslBizDocRegistry.api.ts
|
||||
jeecgboot-vue3/src/views/xslmes/approval/integration/MesXslBizDocRegistry.data.ts
|
||||
|
||||
-- author:GHT---date:20260610--for: 【配合示方】审批进度展示改为关联痕迹表 -----
|
||||
jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/integration/advice/ApprovalTraceResponseAdvice.java
|
||||
jeecgboot-vue3/src/views/xslmes/approval/integration/traceRecordHelper.ts
|
||||
jeecgboot-vue3/src/views/xslmes/mesXslFormulaSpec/MesXslFormulaSpec.data.ts
|
||||
jeecgboot-vue3/src/views/xslmes/mesXslFormulaSpec/components/MesXslFormulaSpecModal.vue
|
||||
|
||||
-- author:GHT---date:20260610--for: 【混炼示方】页脚签章区展示痕迹表审批人/时间 -----
|
||||
jeecgboot-vue3/src/views/xslmes/approval/integration/traceRecordHelper.ts
|
||||
jeecgboot-vue3/src/views/xslmes/mesXslMixingSpec/components/MesXslMixingSpecModal.vue
|
||||
|
||||
-- author:GHT---date:20260610--for: 【配合示方】移除手写痕迹列,改由 useListPage 统一注入 6 列 -----
|
||||
jeecgboot-vue3/src/views/xslmes/mesXslFormulaSpec/MesXslFormulaSpec.data.ts
|
||||
|
||||
-- author:GHT---date:20260610--for: 【混炼示方】页脚起草人/变更人展示姓名 -----
|
||||
jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/entity/MesXslMixingSpec.java
|
||||
jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/service/impl/MesXslMixingSpecServiceImpl.java
|
||||
jeecgboot-vue3/src/views/xslmes/mesXslMixingSpec/components/MesXslMixingSpecModal.vue
|
||||
|
||||
-- author:GHT---date:20260610--for: 【混炼示方】TCU温度条件新增是否附加/重量字段 -----
|
||||
jeecg-boot/jeecg-module-system/jeecg-system-start/src/main/resources/flyway/sql/mysql/V3.9.2_146__mes_xsl_mixing_spec_tcu_attach.sql
|
||||
jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/entity/MesXslMixingSpecTcu.java
|
||||
jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/service/impl/MesXslMixingSpecServiceImpl.java
|
||||
jeecgboot-vue3/src/views/xslmes/mesXslMixingSpec/MesXslMixingSpec.data.ts
|
||||
jeecgboot-vue3/src/views/xslmes/mesXslMixingSpec/components/MesXslMixingSpecModal.vue
|
||||
|
||||
-- author:jiangxh---date:20250602--for: 【MES】设备台账原设备编号改为自定义编号、新增001自增只读系统编号 ---
|
||||
jeecg-boot/jeecg-module-system/jeecg-system-start/src/main/resources/flyway/sql/mysql/V3.9.2_122__mes_xsl_equipment_ledger_ledger_no.sql
|
||||
jeecg-boot/db/mes-xsl-equipment-ledger.sql
|
||||
|
||||
@@ -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 可兼容** | 不破坏现有 Callback;Online 表与 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 配置发布流程
|
||||
|
||||
```
|
||||
草稿方案 → 测试预览(选源单ID,dry-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 走 Service;Online 走 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 推进更新版本号与交付状态。*
|
||||
@@ -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>
|
||||
|
||||
@@ -5,6 +5,7 @@ import lombok.experimental.Accessors;
|
||||
import org.jeecg.modules.xslmes.approval.entity.MesXslApprovalInstance;
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.util.Date;
|
||||
|
||||
/**
|
||||
* 审批回调上下文。
|
||||
@@ -27,7 +28,11 @@ public class ApprovalCallbackContext implements Serializable {
|
||||
/** 整个流程最终通过 */
|
||||
APPROVED,
|
||||
/** 被驳回(任一节点驳回即终止) */
|
||||
REJECTED
|
||||
REJECTED,
|
||||
//update-begin---author:GHT ---date:2026-06-08 for:【风险修复-R5】新增CANCELLED动作,支持撤销时触发业务回滚回调-----------
|
||||
/** 流程被撤销/终止(TERMINATED) */
|
||||
CANCELLED
|
||||
//update-end---author:GHT ---date:2026-06-08 for:【风险修复-R5】新增CANCELLED动作,支持撤销时触发业务回滚回调-----------
|
||||
}
|
||||
|
||||
/** 回调动作 */
|
||||
@@ -60,12 +65,27 @@ 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;
|
||||
|
||||
/** 操作人姓名 */
|
||||
private String operatorName;
|
||||
|
||||
//update-begin---author:GHT ---date:20260608 for:【审批注册中心】环节同步使用实例tasks最新完成时间-----------
|
||||
/** 操作时间(钉钉回调时为 tasks 最新 finishTime) */
|
||||
private Date operatorTime;
|
||||
//update-end---author:GHT ---date:20260608 for:【审批注册中心】环节同步使用实例tasks最新完成时间-----------
|
||||
|
||||
/** 审批意见 / 驳回理由 */
|
||||
private String comment;
|
||||
|
||||
@@ -77,4 +97,18 @@ public class ApprovalCallbackContext implements Serializable {
|
||||
|
||||
/** 完整审批实例(供业务读取租户、发起信息等) */
|
||||
private transient MesXslApprovalInstance instance;
|
||||
|
||||
//update-begin---author:GHT ---date:2026-06-08 for:【缺陷修复-D1/D2】新增token和activityId字段,支持钉钉回调时传递真实审批人身份及节点精确定位-----------
|
||||
/**
|
||||
* 操作人JWT Token(钉钉回调时为审批人真实身份Token;MES内部审批时为null)。
|
||||
* 供 ApprovalActionHttpExecutor 等需要身份的调用方使用。
|
||||
*/
|
||||
private transient String token;
|
||||
|
||||
/**
|
||||
* 钉钉任务节点ID(operationRecords[].activityId,仅钉钉通道有值)。
|
||||
* 可供集成引擎或业务回调按节点精确匹配。
|
||||
*/
|
||||
private String activityId;
|
||||
//update-end---author:GHT ---date:2026-06-08 for:【缺陷修复-D1/D2】新增token和activityId字段,支持钉钉回调时传递真实审批人身份及节点精确定位-----------
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -58,19 +61,51 @@ public class ApprovalCallbackDispatcher {
|
||||
dispatch(ctx);
|
||||
}
|
||||
|
||||
//update-begin---author:GHT ---date:2026-06-08 for:【风险修复-R5】新增fireCancelled,审批撤销时通知业务回滚-----------
|
||||
/** 撤销(TERMINATED) */
|
||||
public void fireCancelled(ApprovalCallbackContext ctx) {
|
||||
ctx.setAction(ApprovalCallbackContext.Action.CANCELLED);
|
||||
ctx.setFinalResult(true);
|
||||
dispatch(ctx);
|
||||
}
|
||||
//update-end---author:GHT ---date:2026-06-08 for:【风险修复-R5】新增fireCancelled,审批撤销时通知业务回滚-----------
|
||||
|
||||
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 +126,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:
|
||||
@@ -102,14 +143,34 @@ public class ApprovalCallbackDispatcher {
|
||||
case REJECTED:
|
||||
cb.onRejected(ctx);
|
||||
break;
|
||||
//update-begin---author:GHT ---date:2026-06-08 for:【风险修复-R5】分发撤销回调-----------
|
||||
case CANCELLED:
|
||||
cb.onCancelled(ctx);
|
||||
break;
|
||||
//update-end---author:GHT ---date:2026-06-08 for:【风险修复-R5】分发撤销回调-----------
|
||||
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来源回调-----------
|
||||
}
|
||||
|
||||
@@ -61,4 +61,14 @@ public interface IApprovalBizCallback {
|
||||
default void onRejected(ApprovalCallbackContext ctx) {
|
||||
// 默认不处理
|
||||
}
|
||||
|
||||
//update-begin---author:GHT ---date:2026-06-08 for:【风险修复-R5】新增撤销回调,TERMINATED时通知业务回滚中间态状态-----------
|
||||
/**
|
||||
* 审批被撤销/终止(TERMINATED)。适合回退业务状态(如置回「草稿」、释放占用等)。
|
||||
* 默认空实现,业务按需重写。
|
||||
*/
|
||||
default void onCancelled(ApprovalCallbackContext ctx) {
|
||||
// 默认不处理
|
||||
}
|
||||
//update-end---author:GHT ---date:2026-06-08 for:【风险修复-R5】新增撤销回调,TERMINATED时通知业务回滚中间态状态-----------
|
||||
}
|
||||
|
||||
@@ -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,38 @@ public class MesXslApprovalFlowController extends JeecgController<MesXslApproval
|
||||
return null;
|
||||
}
|
||||
|
||||
//update-begin---author:GHT ---date:20260605 for:【XSLMES-20260605-K8R2】从审批注册中心解析启用环节-----
|
||||
//update-begin---author:GHT ---date:20260609 for:【审批注册中心】移除 byField 引用,操作人由痕迹表承载-----------
|
||||
/**
|
||||
* 解析表字段,识别审批阶段字段。每个阶段最多取一个字段(优先列注释含"人/员"的人员字段)。
|
||||
* 返回有序列表:[{stageKey, stageName, nodeType, field, fieldComment}]
|
||||
* 从审批注册中心读取已启用环节,映射为流程设计器候选节点。
|
||||
* 返回有序列表:[{stageKey, stageName, nodeType}]
|
||||
*/
|
||||
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, "校对"},
|
||||
{ApprovalStageResolver.STAGE_AUDIT, "审核"},
|
||||
{ApprovalStageResolver.STAGE_APPROVE, "批准"},
|
||||
};
|
||||
for (String[] item : ordered) {
|
||||
if (!enabled.contains(item[0])) {
|
||||
continue;
|
||||
}
|
||||
Map<String, Object> stage = new LinkedHashMap<>();
|
||||
stage.put("stageKey", item[0]);
|
||||
stage.put("stageName", item[1]);
|
||||
stage.put("nodeType", "approver");
|
||||
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:20260609 for:【审批注册中心】移除 byField 引用,操作人由痕迹表承载-----------
|
||||
//update-end---author:GHT ---date:20260605 for:【XSLMES-20260605-K8R2】从审批注册中心解析启用环节-----
|
||||
|
||||
/** 按业务表+租户查找审批流(取最近一条) */
|
||||
private MesXslApprovalFlow findFlowByTable(String table, Integer tenantId) {
|
||||
|
||||
@@ -99,6 +99,20 @@ 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】台账增加编排执行状态字段-----
|
||||
|
||||
//update-begin---author:GHT ---date:20260605 for:【XSLMES-20260605-K8R4】新增nodeActivityMap存储processForecast节点顺序映射-----------
|
||||
@Schema(description = "钉钉节点活动映射(processForecast结果, JSON数组, 含completionAt幂等边界, 会签/依次审批多人等待判断依据)")
|
||||
private String nodeActivityMap;
|
||||
//update-end---author:GHT ---date:20260605 for:【XSLMES-20260605-K8R4】新增nodeActivityMap存储processForecast节点顺序映射-----------
|
||||
|
||||
@Schema(description = "逻辑删除 0正常 1已删除")
|
||||
@TableLogic
|
||||
private Integer delFlag;
|
||||
|
||||
@@ -0,0 +1,298 @@
|
||||
package org.jeecg.modules.xslmes.approval.integration.advice;
|
||||
|
||||
import com.alibaba.fastjson2.JSON;
|
||||
import com.baomidou.mybatisplus.core.metadata.IPage;
|
||||
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.jeecg.common.api.vo.Result;
|
||||
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.IMesXslApprovalTraceService;
|
||||
import org.jeecg.modules.xslmes.approval.integration.service.IMesXslBizDocRegistryService;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.core.MethodParameter;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.converter.HttpMessageConverter;
|
||||
import org.springframework.http.server.ServerHttpRequest;
|
||||
import org.springframework.http.server.ServerHttpResponse;
|
||||
import org.springframework.http.server.ServletServerHttpRequest;
|
||||
import org.springframework.web.bind.annotation.ControllerAdvice;
|
||||
import org.springframework.web.servlet.mvc.method.annotation.ResponseBodyAdvice;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.HashMap;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 审批痕迹自动注入增强器
|
||||
*
|
||||
* <p>当审批注册中心配置了 listApiPath 后,拦截匹配 URL 的列表响应,
|
||||
* 自动 LEFT JOIN mes_xsl_approval_trace,将痕迹字段(traceProofreadBy 等)
|
||||
* 注入到每条记录中,无需修改业务代码。
|
||||
*
|
||||
* @author GHT
|
||||
* @date 2026-06-08 for:【XSLMES-20260608-TRACE】审批痕迹响应自动注入
|
||||
*/
|
||||
//update-begin---author:GHT ---date:20260608 for:【XSLMES-20260608-TRACE】审批痕迹响应自动注入-----------
|
||||
@ControllerAdvice
|
||||
@Slf4j
|
||||
@SuppressWarnings({"unchecked", "rawtypes"})
|
||||
public class ApprovalTraceResponseAdvice implements ResponseBodyAdvice<Object> {
|
||||
|
||||
@Autowired
|
||||
private IMesXslBizDocRegistryService registryService;
|
||||
|
||||
@Autowired
|
||||
private IMesXslApprovalTraceService traceService;
|
||||
|
||||
/** 路径缓存条目 */
|
||||
private static class CacheEntry {
|
||||
final String tableName;
|
||||
/** enabledStages 集合,如 {"proofread","audit","approve"} */
|
||||
final java.util.Set<String> enabledStages;
|
||||
CacheEntry(String tableName, java.util.Set<String> enabledStages) {
|
||||
this.tableName = tableName;
|
||||
this.enabledStages = enabledStages;
|
||||
}
|
||||
}
|
||||
|
||||
/** path → CacheEntry 缓存(1 分钟 TTL)*/
|
||||
private volatile Map<String, CacheEntry> pathToEntryCache = Collections.emptyMap();
|
||||
private volatile long cacheLoadTime = 0L;
|
||||
private static final long CACHE_TTL_MS = 60_000L;
|
||||
|
||||
@Override
|
||||
public boolean supports(MethodParameter returnType,
|
||||
Class<? extends HttpMessageConverter<?>> converterType) {
|
||||
return Result.class.isAssignableFrom(returnType.getParameterType());
|
||||
}
|
||||
|
||||
@Override
|
||||
public Object beforeBodyWrite(Object body,
|
||||
MethodParameter returnType,
|
||||
MediaType selectedContentType,
|
||||
Class<? extends HttpMessageConverter<?>> selectedConverterType,
|
||||
ServerHttpRequest request,
|
||||
ServerHttpResponse response) {
|
||||
if (!(body instanceof Result)) {
|
||||
return body;
|
||||
}
|
||||
String path = extractServletPath(request);
|
||||
CacheEntry entry = resolveEntry(path);
|
||||
if (entry == null) {
|
||||
entry = resolveEntryByQueryById(path);
|
||||
}
|
||||
if (entry == null) {
|
||||
return body;
|
||||
}
|
||||
|
||||
Result result = (Result) body;
|
||||
Object data = result.getResult();
|
||||
|
||||
List<?> records = null;
|
||||
IPage page = null;
|
||||
boolean singleEntity = false;
|
||||
if (data instanceof IPage) {
|
||||
page = (IPage) data;
|
||||
records = page.getRecords();
|
||||
} else if (data instanceof List) {
|
||||
records = (List<?>) data;
|
||||
} else if (data != null && extractId(data) != null) {
|
||||
records = Collections.singletonList(data);
|
||||
singleEntity = true;
|
||||
}
|
||||
|
||||
if (records == null || records.isEmpty()) {
|
||||
return body;
|
||||
}
|
||||
|
||||
List<String> ids = extractIds(records);
|
||||
Map<String, MesXslApprovalTrace> traceMap = Collections.emptyMap();
|
||||
if (!ids.isEmpty()) {
|
||||
try {
|
||||
traceMap = traceService.batchQueryByBizIds(entry.tableName, ids);
|
||||
} catch (Exception e) {
|
||||
log.warn("[审批痕迹注入] 批量查询失败 table={} path={}: {}", entry.tableName, path, e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
List<Map<String, Object>> enriched = enrichRecords(records, traceMap, entry.enabledStages);
|
||||
|
||||
if (page != null) {
|
||||
((Page) page).setRecords(enriched);
|
||||
} else if (singleEntity) {
|
||||
result.setResult(enriched.get(0));
|
||||
} else {
|
||||
result.setResult(enriched);
|
||||
}
|
||||
return body;
|
||||
}
|
||||
|
||||
private String extractServletPath(ServerHttpRequest request) {
|
||||
if (request instanceof ServletServerHttpRequest) {
|
||||
HttpServletRequest servletRequest = ((ServletServerHttpRequest) request).getServletRequest();
|
||||
String path = servletRequest.getServletPath();
|
||||
return oConvertUtils.isNotEmpty(path) ? path : request.getURI().getPath();
|
||||
}
|
||||
return request.getURI().getPath();
|
||||
}
|
||||
|
||||
private CacheEntry resolveEntry(String path) {
|
||||
if (oConvertUtils.isEmpty(path)) {
|
||||
return null;
|
||||
}
|
||||
ensureCacheLoaded();
|
||||
return pathToEntryCache.get(path);
|
||||
}
|
||||
|
||||
/** queryById 与 list 同模块时,按 list 路径匹配注册中心配置 */
|
||||
private CacheEntry resolveEntryByQueryById(String path) {
|
||||
if (oConvertUtils.isEmpty(path) || !path.endsWith("/queryById")) {
|
||||
return null;
|
||||
}
|
||||
ensureCacheLoaded();
|
||||
String listPath = path.substring(0, path.length() - "/queryById".length()) + "/list";
|
||||
return pathToEntryCache.get(listPath);
|
||||
}
|
||||
|
||||
private String extractId(Object r) {
|
||||
if (r == null) {
|
||||
return null;
|
||||
}
|
||||
Object id = null;
|
||||
if (r instanceof Map) {
|
||||
id = ((Map<?, ?>) r).get("id");
|
||||
} else {
|
||||
try {
|
||||
id = r.getClass().getMethod("getId").invoke(r);
|
||||
} catch (Exception ignored) {
|
||||
// 无 getId 方法
|
||||
}
|
||||
}
|
||||
if (id == null) {
|
||||
return null;
|
||||
}
|
||||
String idStr = String.valueOf(id);
|
||||
return oConvertUtils.isNotEmpty(idStr) ? idStr : null;
|
||||
}
|
||||
|
||||
private void ensureCacheLoaded() {
|
||||
long now = System.currentTimeMillis();
|
||||
if (now - cacheLoadTime > CACHE_TTL_MS) {
|
||||
synchronized (this) {
|
||||
if (now - cacheLoadTime > CACHE_TTL_MS) {
|
||||
reloadCache();
|
||||
cacheLoadTime = now;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void reloadCache() {
|
||||
try {
|
||||
List<MesXslBizDocRegistry> registries = registryService.lambdaQuery()
|
||||
.eq(MesXslBizDocRegistry::getEnabled, 1)
|
||||
.isNotNull(MesXslBizDocRegistry::getListApiPath)
|
||||
.list();
|
||||
Map<String, CacheEntry> map = new HashMap<>();
|
||||
for (MesXslBizDocRegistry reg : registries) {
|
||||
if (oConvertUtils.isEmpty(reg.getListApiPath()) || oConvertUtils.isEmpty(reg.getTableName())) {
|
||||
continue;
|
||||
}
|
||||
java.util.Set<String> stages = parseStages(reg.getEnabledStages());
|
||||
CacheEntry entry = new CacheEntry(reg.getTableName(), stages);
|
||||
for (String p : reg.getListApiPath().split(",")) {
|
||||
String trimmed = p.trim();
|
||||
if (oConvertUtils.isNotEmpty(trimmed)) {
|
||||
map.put(trimmed, entry);
|
||||
}
|
||||
}
|
||||
}
|
||||
pathToEntryCache = map;
|
||||
log.debug("[审批痕迹注入] 路径缓存已刷新,共 {} 条路径映射", map.size());
|
||||
} catch (Exception e) {
|
||||
log.warn("[审批痕迹注入] 路径缓存刷新失败: {}", e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private java.util.Set<String> parseStages(String enabledStages) {
|
||||
java.util.Set<String> set = new java.util.LinkedHashSet<>();
|
||||
if (oConvertUtils.isEmpty(enabledStages)) {
|
||||
return set;
|
||||
}
|
||||
for (String s : enabledStages.split(",")) {
|
||||
String t = s.trim();
|
||||
if (oConvertUtils.isNotEmpty(t)) {
|
||||
set.add(t);
|
||||
}
|
||||
}
|
||||
return set;
|
||||
}
|
||||
|
||||
private List<String> extractIds(List<?> records) {
|
||||
List<String> ids = new ArrayList<>(records.size());
|
||||
for (Object r : records) {
|
||||
if (r == null) {
|
||||
continue;
|
||||
}
|
||||
Object id = null;
|
||||
if (r instanceof Map) {
|
||||
id = ((Map<?, ?>) r).get("id");
|
||||
} else {
|
||||
try {
|
||||
id = r.getClass().getMethod("getId").invoke(r);
|
||||
} catch (Exception ignored) {
|
||||
// 无 getId 方法时跳过
|
||||
}
|
||||
}
|
||||
if (id != null) {
|
||||
String idStr = String.valueOf(id);
|
||||
if (oConvertUtils.isNotEmpty(idStr)) {
|
||||
ids.add(idStr);
|
||||
}
|
||||
}
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
|
||||
private List<Map<String, Object>> enrichRecords(List<?> records,
|
||||
Map<String, MesXslApprovalTrace> traceMap,
|
||||
java.util.Set<String> enabledStages) {
|
||||
List<Map<String, Object>> enriched = new ArrayList<>(records.size());
|
||||
for (Object r : records) {
|
||||
if (r == null) {
|
||||
continue;
|
||||
}
|
||||
Map<String, Object> map;
|
||||
if (r instanceof Map) {
|
||||
map = new LinkedHashMap<>((Map<String, Object>) r);
|
||||
} else {
|
||||
// 实体类转 Map(保留序列化配置如 @JsonFormat)
|
||||
map = new LinkedHashMap<>(JSON.parseObject(JSON.toJSONString(r), Map.class));
|
||||
}
|
||||
Object idObj = map.get("id");
|
||||
MesXslApprovalTrace trace = (idObj != null) ? traceMap.get(String.valueOf(idObj)) : null;
|
||||
// 对每个启用的环节,始终注入字段(无痕迹时为 null),使前端能感知注册了哪些列
|
||||
if (enabledStages.contains("proofread")) {
|
||||
map.put("traceProofreadBy", trace != null ? trace.getProofreadBy() : null);
|
||||
map.put("traceProofreadTime", trace != null ? trace.getProofreadTime() : null);
|
||||
}
|
||||
if (enabledStages.contains("audit")) {
|
||||
map.put("traceAuditBy", trace != null ? trace.getAuditBy() : null);
|
||||
map.put("traceAuditTime", trace != null ? trace.getAuditTime() : null);
|
||||
}
|
||||
if (enabledStages.contains("approve")) {
|
||||
map.put("traceApproveBy", trace != null ? trace.getApproveBy() : null);
|
||||
map.put("traceApproveTime", trace != null ? trace.getApproveTime() : null);
|
||||
}
|
||||
enriched.add(map);
|
||||
}
|
||||
return enriched;
|
||||
}
|
||||
}
|
||||
//update-end---author:GHT ---date:20260608 for:【XSLMES-20260608-TRACE】审批痕迹响应自动注入-----------
|
||||
@@ -0,0 +1,174 @@
|
||||
package org.jeecg.modules.xslmes.approval.integration.controller;
|
||||
|
||||
import com.alibaba.fastjson2.JSONObject;
|
||||
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.jeecg.modules.xslmes.approval.integration.vo.DingProcessForecastVO;
|
||||
import org.jeecg.modules.xslmes.approval.integration.vo.DingProcessInstanceFlowVO;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 审批痕迹明细
|
||||
*
|
||||
* @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");
|
||||
//update-begin---author:GHT ---date:20260608 for:【审批注册中心】明细列表补充钉钉审批实例ID-----------
|
||||
return Result.OK(traceService.pageWithDingInstanceId(new Page<>(pageNo, pageSize), qw));
|
||||
//update-end---author:GHT ---date:20260608 for:【审批注册中心】明细列表补充钉钉审批实例ID-----------
|
||||
}
|
||||
|
||||
@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("未找到对应数据");
|
||||
}
|
||||
|
||||
//update-begin---author:GHT ---date:20260608 for:【XSLMES-20260608-TRACE】批量查询痕迹供前端关联展示-----------
|
||||
@Operation(summary = "审批痕迹-批量查询(bizTable + 单据ID列表,供前端或内部关联展示)")
|
||||
@RequiresPermissions("xslmes:mes_xsl_approval_trace:list")
|
||||
@PostMapping("/batchByBizIds")
|
||||
public Result<Map<String, MesXslApprovalTrace>> batchByBizIds(
|
||||
@RequestParam String bizTable,
|
||||
@RequestBody List<String> bizDataIds) {
|
||||
if (oConvertUtils.isEmpty(bizTable) || bizDataIds == null || bizDataIds.isEmpty()) {
|
||||
return Result.error("bizTable 与 bizDataIds 不能为空");
|
||||
}
|
||||
return Result.OK(traceService.batchQueryByBizIds(bizTable, bizDataIds));
|
||||
}
|
||||
//update-end---author:GHT ---date:20260608 for:【XSLMES-20260608-TRACE】批量查询痕迹供前端关联展示-----------
|
||||
|
||||
@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);
|
||||
}
|
||||
|
||||
//update-begin---author:GHT ---date:20260608 for:【审批注册中心】查看钉钉审批流转记录-----------
|
||||
@Operation(summary = "审批痕迹-钉钉审批流转记录(时间轴)")
|
||||
@RequiresPermissions(value = {"xslmes:mes_xsl_approval_trace:list", "xslmes:mes_xsl_biz_doc_registry:trace"}, logical = Logical.OR)
|
||||
@GetMapping("/dingFlowRecords")
|
||||
public Result<DingProcessInstanceFlowVO> dingFlowRecords(
|
||||
@RequestParam(required = false) String bizTable,
|
||||
@RequestParam(required = false) String bizDataId,
|
||||
@RequestParam(required = false) String processInstanceId) {
|
||||
if (oConvertUtils.isEmpty(processInstanceId)
|
||||
&& (oConvertUtils.isEmpty(bizTable) || oConvertUtils.isEmpty(bizDataId))) {
|
||||
return Result.error("单据ID与钉钉审批流ID不能同时为空");
|
||||
}
|
||||
try {
|
||||
return Result.OK(traceService.getDingFlowRecords(bizTable, bizDataId, processInstanceId));
|
||||
} catch (IllegalArgumentException e) {
|
||||
return Result.error(e.getMessage());
|
||||
} catch (IllegalStateException e) {
|
||||
return Result.error(e.getMessage());
|
||||
} catch (Exception e) {
|
||||
log.error("拉取钉钉审批流转记录失败 bizTable={} bizDataId={} processInstanceId={}: {}",
|
||||
bizTable, bizDataId, processInstanceId, e.getMessage(), e);
|
||||
return Result.error("拉取钉钉审批流转记录失败:" + e.getMessage());
|
||||
}
|
||||
}
|
||||
//update-end---author:GHT ---date:20260608 for:【审批注册中心】查看钉钉审批流转记录-----------
|
||||
|
||||
//update-begin---author:GHT ---date:20260608 for:【审批注册中心】审批节点改由实例tasks按activityId解析-----------
|
||||
@Operation(summary = "审批痕迹-钉钉审批节点(实例tasks解析)")
|
||||
@RequiresPermissions(value = {"xslmes:mes_xsl_approval_trace:list", "xslmes:mes_xsl_biz_doc_registry:trace"}, logical = Logical.OR)
|
||||
@GetMapping("/dingProcessForecast")
|
||||
public Result<DingProcessForecastVO> dingProcessForecast(
|
||||
@RequestParam(required = false) String bizTable,
|
||||
@RequestParam(required = false) String bizDataId,
|
||||
@RequestParam(required = false) String processInstanceId) {
|
||||
if (oConvertUtils.isEmpty(processInstanceId)
|
||||
&& (oConvertUtils.isEmpty(bizTable) || oConvertUtils.isEmpty(bizDataId))) {
|
||||
return Result.error("单据ID与钉钉审批流ID不能同时为空");
|
||||
}
|
||||
try {
|
||||
return Result.OK(traceService.getDingProcessForecast(bizTable, bizDataId, processInstanceId));
|
||||
} catch (IllegalArgumentException e) {
|
||||
return Result.error(e.getMessage());
|
||||
} catch (IllegalStateException e) {
|
||||
return Result.error(e.getMessage());
|
||||
} catch (Exception e) {
|
||||
log.error("获取钉钉审批节点失败 bizTable={} bizDataId={} processInstanceId={}: {}",
|
||||
bizTable, bizDataId, processInstanceId, e.getMessage(), e);
|
||||
return Result.error("获取钉钉审批节点失败:" + e.getMessage());
|
||||
}
|
||||
}
|
||||
//update-end---author:GHT ---date:20260608 for:【审批注册中心】审批节点改由实例tasks按activityId解析-----------
|
||||
|
||||
//update-begin---author:GHT ---date:20260608 for:【审批注册中心】查看钉钉审批实例原始JSON-----------
|
||||
@Operation(summary = "审批痕迹-钉钉审批实例原始JSON")
|
||||
@RequiresPermissions(value = {"xslmes:mes_xsl_approval_trace:list", "xslmes:mes_xsl_biz_doc_registry:trace"}, logical = Logical.OR)
|
||||
@GetMapping("/dingProcessInstance")
|
||||
public Result<JSONObject> dingProcessInstance(
|
||||
@RequestParam(required = false) String bizTable,
|
||||
@RequestParam(required = false) String bizDataId,
|
||||
@RequestParam(required = false) String processInstanceId) {
|
||||
if (oConvertUtils.isEmpty(processInstanceId)
|
||||
&& (oConvertUtils.isEmpty(bizTable) || oConvertUtils.isEmpty(bizDataId))) {
|
||||
return Result.error("单据ID与钉钉审批流ID不能同时为空");
|
||||
}
|
||||
try {
|
||||
return Result.OK(traceService.getDingProcessInstance(bizTable, bizDataId, processInstanceId));
|
||||
} catch (IllegalArgumentException e) {
|
||||
return Result.error(e.getMessage());
|
||||
} catch (IllegalStateException e) {
|
||||
return Result.error(e.getMessage());
|
||||
} catch (Exception e) {
|
||||
log.error("拉取钉钉审批实例原始JSON失败 bizTable={} bizDataId={} processInstanceId={}: {}",
|
||||
bizTable, bizDataId, processInstanceId, e.getMessage(), e);
|
||||
return Result.error("拉取钉钉审批实例失败:" + e.getMessage());
|
||||
}
|
||||
}
|
||||
//update-end---author:GHT ---date:20260608 for:【审批注册中心】查看钉钉审批实例原始JSON-----------
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
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.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 审批注册中心
|
||||
*
|
||||
* @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;
|
||||
@Autowired
|
||||
private JdbcTemplate jdbcTemplate;
|
||||
|
||||
@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("未找到对应数据");
|
||||
}
|
||||
|
||||
//update-begin---author:GHT ---date:20260610 for:【审批注册中心】物理表名下拉选择,查询当前库表清单-----------
|
||||
@Operation(summary = "查询当前数据库物理表(供注册中心下拉选择)")
|
||||
@RequiresPermissions("xslmes:mes_xsl_biz_doc_registry:list")
|
||||
@GetMapping("/dbTables")
|
||||
public Result<List<Map<String, String>>> listDbTables(
|
||||
@RequestParam(name = "keyword", required = false) String keyword) {
|
||||
StringBuilder sql = new StringBuilder(
|
||||
"SELECT TABLE_NAME tableName, IFNULL(TABLE_COMMENT,'') tableComment "
|
||||
+ "FROM information_schema.tables "
|
||||
+ "WHERE table_schema = (SELECT DATABASE()) AND table_type = 'BASE TABLE' ");
|
||||
List<Object> args = new ArrayList<>();
|
||||
if (keyword != null && !keyword.isBlank()) {
|
||||
String like = "%" + keyword.trim() + "%";
|
||||
sql.append("AND (TABLE_NAME LIKE ? OR TABLE_COMMENT LIKE ?) ");
|
||||
args.add(like);
|
||||
args.add(like);
|
||||
}
|
||||
sql.append("ORDER BY TABLE_NAME");
|
||||
List<Map<String, Object>> rows = jdbcTemplate.queryForList(sql.toString(), args.toArray());
|
||||
List<Map<String, String>> options = new ArrayList<>(rows.size());
|
||||
for (Map<String, Object> row : rows) {
|
||||
String tableName = String.valueOf(row.get("tableName"));
|
||||
String comment = String.valueOf(row.get("tableComment"));
|
||||
Map<String, String> opt = new LinkedHashMap<>();
|
||||
opt.put("value", tableName);
|
||||
opt.put("comment", comment);
|
||||
opt.put("label", comment.isBlank() ? tableName : tableName + "(" + comment + ")");
|
||||
options.add(opt);
|
||||
}
|
||||
return Result.OK(options);
|
||||
}
|
||||
//update-end---author:GHT ---date:20260610 for:【审批注册中心】物理表名下拉选择,查询当前库表清单-----------
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,251 @@
|
||||
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.util.oConvertUtils;
|
||||
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-begin---author:GHT ---date:2026-06-10 for:【审批流设计】单节点生成集成方案-----------
|
||||
@Operation(summary = "为单个审批节点生成集成方案(流程设计器内使用)")
|
||||
@RequiresPermissions("xslmes:mes_xsl_integration_plan:edit")
|
||||
@PostMapping("/generateForNode")
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public Result<Map<String, Object>> generateForNode(@RequestBody Map<String, Object> body) {
|
||||
if (body == null) {
|
||||
return Result.error("请求体不能为空");
|
||||
}
|
||||
String sourceTable = body.get("sourceTable") != null ? String.valueOf(body.get("sourceTable")) : null;
|
||||
String flowId = body.get("flowId") != null ? String.valueOf(body.get("flowId")) : null;
|
||||
String nodeId = body.get("nodeId") != null ? String.valueOf(body.get("nodeId")) : null;
|
||||
String stageKey = body.get("stageKey") != null ? String.valueOf(body.get("stageKey")) : null;
|
||||
String flowConfig = body.get("flowConfig") != null ? String.valueOf(body.get("flowConfig")) : null;
|
||||
boolean overwriteDraft = Boolean.TRUE.equals(body.get("overwriteDraft"));
|
||||
return planGenerator.generateForNode(sourceTable, flowId, nodeId, stageKey, flowConfig, overwriteDraft);
|
||||
}
|
||||
//update-end---author:GHT ---date:2026-06-10 for:【审批流设计】单节点生成集成方案-----------
|
||||
//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) {
|
||||
normalizeRegistryAction(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) {
|
||||
normalizeRegistryAction(action);
|
||||
actionService.updateById(action);
|
||||
return Result.OK("编辑成功");
|
||||
}
|
||||
|
||||
//update-begin---author:GHT ---date:20260608 for:【审核集成】环节同步/回退动作保存时清理无效SQL模板-----------
|
||||
/** REGISTRY 类动作不走 SQL_UPDATE,保存时强制清空 sql_template 避免脏数据 */
|
||||
private void normalizeRegistryAction(MesXslIntegrationAction action) {
|
||||
if (action == null || oConvertUtils.isEmpty(action.getActionType())) {
|
||||
return;
|
||||
}
|
||||
if ("REGISTRY_STAGE_SYNC".equals(action.getActionType())
|
||||
|| "REGISTRY_STAGE_REVERT".equals(action.getActionType())) {
|
||||
action.setSqlTemplate(null);
|
||||
}
|
||||
}
|
||||
//update-end---author:GHT ---date:20260608 for:【审核集成】环节同步/回退动作保存时清理无效SQL模板-----------
|
||||
|
||||
@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("删除成功");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,594 @@
|
||||
package org.jeecg.modules.xslmes.approval.integration.engine;
|
||||
|
||||
import com.alibaba.fastjson2.JSONArray;
|
||||
import com.alibaba.fastjson2.JSONObject;
|
||||
import lombok.Data;
|
||||
import lombok.experimental.Accessors;
|
||||
import org.jeecg.common.util.oConvertUtils;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.text.ParseException;
|
||||
import java.text.SimpleDateFormat;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collection;
|
||||
import java.util.Comparator;
|
||||
import java.util.Date;
|
||||
import java.util.HashMap;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* 从钉钉审批实例 tasks 按 activityId 解析环节完成情况,并与 MES 审批流节点 stageKey / multiMode 对齐。
|
||||
*/
|
||||
@Component
|
||||
public class ApprovalInstanceStageExtractor {
|
||||
|
||||
private static final Set<String> TRACE_STAGES = Set.of(
|
||||
ApprovalStageResolver.STAGE_PROOFREAD,
|
||||
ApprovalStageResolver.STAGE_AUDIT,
|
||||
ApprovalStageResolver.STAGE_APPROVE);
|
||||
|
||||
private final JdbcTemplate jdbcTemplate;
|
||||
|
||||
public ApprovalInstanceStageExtractor(JdbcTemplate jdbcTemplate) {
|
||||
this.jdbcTemplate = jdbcTemplate;
|
||||
}
|
||||
|
||||
//update-begin---author:GHT ---date:20260608 for:【审批注册中心】按MES multiMode解析节点状态与审批人-----------
|
||||
/**
|
||||
* 按 MES 审批流节点顺序与实例 tasks 的 activityId 顺序对齐,解析各环节已完成操作人及最新时间。
|
||||
*/
|
||||
public List<StageCompletion> resolveCompletedStages(JSONObject instance, String flowConfig) {
|
||||
List<StageCompletion> completions = new ArrayList<>();
|
||||
if (instance == null || oConvertUtils.isEmpty(flowConfig)) {
|
||||
return completions;
|
||||
}
|
||||
List<NodePair> pairs = alignMesNodesWithTasks(instance, flowConfig);
|
||||
for (NodePair pair : pairs) {
|
||||
String stageKey = resolveStageKey(pair.getMesNode());
|
||||
if (!isTraceStage(stageKey)) {
|
||||
continue;
|
||||
}
|
||||
NodeTaskDecision decision = evaluateNodeTasks(pair.getTaskList(), resolveApprovalMethod(pair.getMesNode()));
|
||||
if (!decision.isAgreed()) {
|
||||
continue;
|
||||
}
|
||||
StageCompletion completion = toStageCompletion(stageKey, pair.getActivityId(), decision);
|
||||
if (completion != null) {
|
||||
completions.add(completion);
|
||||
}
|
||||
}
|
||||
return completions;
|
||||
}
|
||||
|
||||
public LinkedHashMap<String, List<JSONObject>> groupTasksByActivityId(JSONObject instance) {
|
||||
LinkedHashMap<String, List<JSONObject>> grouped = new LinkedHashMap<>();
|
||||
JSONArray tasks = instance.getJSONArray("tasks");
|
||||
if (tasks == null || tasks.isEmpty()) {
|
||||
return grouped;
|
||||
}
|
||||
for (int i = 0; i < tasks.size(); i++) {
|
||||
JSONObject task = tasks.getJSONObject(i);
|
||||
if (task == null) {
|
||||
continue;
|
||||
}
|
||||
String activityId = task.getString("activityId");
|
||||
if (oConvertUtils.isEmpty(activityId)) {
|
||||
continue;
|
||||
}
|
||||
grouped.computeIfAbsent(activityId, k -> new ArrayList<>()).add(task);
|
||||
}
|
||||
return grouped;
|
||||
}
|
||||
|
||||
public List<String> listOrderedActivityIds(JSONObject instance) {
|
||||
return new ArrayList<>(groupTasksByActivityId(instance).keySet());
|
||||
}
|
||||
|
||||
public int resolveStepIndexFromTasks(JSONObject instance, String activityId) {
|
||||
if (instance == null || oConvertUtils.isEmpty(activityId)) {
|
||||
return -1;
|
||||
}
|
||||
return listOrderedActivityIds(instance).indexOf(activityId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 提取某 activityId 节点完成时的审批人及时间(按 MES multiMode 判定)。
|
||||
*/
|
||||
public StageCompletion extractActivityCompletion(JSONObject instance, String activityId, JSONObject mesNode) {
|
||||
if (instance == null || oConvertUtils.isEmpty(activityId)) {
|
||||
return null;
|
||||
}
|
||||
List<JSONObject> taskList = groupTasksByActivityId(instance).get(activityId);
|
||||
String approvalMethod = mesNode == null ? "NONE" : resolveApprovalMethod(mesNode);
|
||||
NodeTaskDecision decision = evaluateNodeTasks(taskList, approvalMethod);
|
||||
if (!decision.isAgreed()) {
|
||||
return null;
|
||||
}
|
||||
return toStageCompletion(null, activityId, decision);
|
||||
}
|
||||
|
||||
public List<JSONObject> loadMesApproverNodes(String flowConfig) {
|
||||
List<JSONObject> result = new ArrayList<>();
|
||||
if (oConvertUtils.isEmpty(flowConfig)) {
|
||||
return result;
|
||||
}
|
||||
try {
|
||||
collectAllApproverNodes(JSONObject.parseObject(flowConfig), result);
|
||||
} catch (Exception ignored) {
|
||||
// 解析失败返回空列表
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
public List<NodePair> alignMesNodesWithTasks(JSONObject instance, String flowConfig) {
|
||||
List<NodePair> pairs = new ArrayList<>();
|
||||
LinkedHashMap<String, List<JSONObject>> grouped = groupTasksByActivityId(instance);
|
||||
if (grouped.isEmpty()) {
|
||||
return pairs;
|
||||
}
|
||||
List<JSONObject> mesNodes = loadMesApproverNodes(flowConfig);
|
||||
if (mesNodes.isEmpty()) {
|
||||
return pairs;
|
||||
}
|
||||
List<String> activityOrder = new ArrayList<>(grouped.keySet());
|
||||
int pairCount = Math.min(mesNodes.size(), activityOrder.size());
|
||||
for (int i = 0; i < pairCount; i++) {
|
||||
NodePair pair = new NodePair();
|
||||
pair.setStepNo(i + 1);
|
||||
pair.setMesNode(mesNodes.get(i));
|
||||
pair.setActivityId(activityOrder.get(i));
|
||||
pair.setTaskList(grouped.get(activityOrder.get(i)));
|
||||
pairs.add(pair);
|
||||
}
|
||||
return pairs;
|
||||
}
|
||||
|
||||
public String resolveStageKey(JSONObject mesNode) {
|
||||
if (mesNode == null) {
|
||||
return null;
|
||||
}
|
||||
JSONObject props = mesNode.getJSONObject("props");
|
||||
if (props == null) {
|
||||
return null;
|
||||
}
|
||||
String stageKey = props.getString("stageKey");
|
||||
return oConvertUtils.isEmpty(stageKey) ? null : stageKey.trim();
|
||||
}
|
||||
|
||||
/** 从 MES 审批流节点 props.multiMode 映射钉钉审批方式 */
|
||||
public String resolveApprovalMethod(JSONObject mesNode) {
|
||||
if (mesNode == null) {
|
||||
return "NONE";
|
||||
}
|
||||
JSONObject props = mesNode.getJSONObject("props");
|
||||
if (props == null) {
|
||||
return "NONE";
|
||||
}
|
||||
String multiMode = props.getString("multiMode");
|
||||
if (oConvertUtils.isEmpty(multiMode) || "none".equalsIgnoreCase(multiMode)) {
|
||||
return "NONE";
|
||||
}
|
||||
if ("or".equalsIgnoreCase(multiMode)) {
|
||||
return "OR";
|
||||
}
|
||||
if ("and".equalsIgnoreCase(multiMode)) {
|
||||
return "AND";
|
||||
}
|
||||
if ("sequence".equalsIgnoreCase(multiMode)) {
|
||||
return "ONE_BY_ONE";
|
||||
}
|
||||
return "NONE";
|
||||
}
|
||||
|
||||
public String approvalMethodText(String approvalMethod) {
|
||||
if (oConvertUtils.isEmpty(approvalMethod)) {
|
||||
return "单人审批";
|
||||
}
|
||||
return switch (approvalMethod.toUpperCase()) {
|
||||
case "AND" -> "会签";
|
||||
case "OR" -> "或签";
|
||||
case "ONE_BY_ONE" -> "依次审批";
|
||||
case "NONE" -> "单人审批";
|
||||
default -> approvalMethod;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 按审批方式解析节点状态与应展示的审批人。
|
||||
* 或签:任一通过/拒绝即定论,只取实际操作人;会签:全部通过才完成,取全部通过人。
|
||||
*/
|
||||
public NodeTaskDecision evaluateNodeTasks(List<JSONObject> taskList, String approvalMethod) {
|
||||
NodeTaskDecision decision = new NodeTaskDecision();
|
||||
decision.setNodeStatus("UNKNOWN");
|
||||
if (taskList == null || taskList.isEmpty()) {
|
||||
decision.setNodeStatus("NEW");
|
||||
decision.setNodeStatusText(nodeStatusText("NEW"));
|
||||
return decision;
|
||||
}
|
||||
String method = normalizeApprovalMethod(approvalMethod);
|
||||
|
||||
JSONObject refuseTask = findFirstActedTask(taskList, "REFUSE");
|
||||
if (refuseTask != null) {
|
||||
decision.setNodeStatus("REFUSED");
|
||||
decision.setNodeStatusText(nodeStatusText("REFUSED"));
|
||||
decision.setRefused(true);
|
||||
decision.setActorUserIds(List.of(refuseTask.getString("userId")));
|
||||
decision.setOperatorTime(parseFinishTime(refuseTask.getString("finishTime")));
|
||||
return decision;
|
||||
}
|
||||
|
||||
if ("OR".equals(method)) {
|
||||
JSONObject agreeTask = findFirstActedTask(taskList, "AGREE");
|
||||
if (agreeTask != null) {
|
||||
decision.setNodeStatus("COMPLETED");
|
||||
decision.setNodeStatusText(nodeStatusText("COMPLETED"));
|
||||
decision.setAgreed(true);
|
||||
decision.setActorUserIds(List.of(agreeTask.getString("userId")));
|
||||
decision.setOperatorTime(parseFinishTime(agreeTask.getString("finishTime")));
|
||||
return decision;
|
||||
}
|
||||
return decisionFromPendingTasks(taskList);
|
||||
}
|
||||
|
||||
if ("AND".equals(method) || "ONE_BY_ONE".equals(method)) {
|
||||
if (hasRunningOrNew(taskList)) {
|
||||
decision.setNodeStatus("RUNNING");
|
||||
decision.setNodeStatusText(nodeStatusText("RUNNING"));
|
||||
decision.setActorUserIds(listAllAssigneeIds(taskList));
|
||||
return decision;
|
||||
}
|
||||
List<JSONObject> agreeTasks = findAllActedTasks(taskList, "AGREE");
|
||||
int activeCount = countActiveTasks(taskList);
|
||||
if (activeCount > 0 && agreeTasks.size() >= activeCount) {
|
||||
decision.setNodeStatus("COMPLETED");
|
||||
decision.setNodeStatusText(nodeStatusText("COMPLETED"));
|
||||
decision.setAgreed(true);
|
||||
decision.setActorUserIds(extractOrderedUserIds(agreeTasks));
|
||||
decision.setOperatorTime(latestFinishTime(agreeTasks));
|
||||
return decision;
|
||||
}
|
||||
return decisionFromPendingTasks(taskList);
|
||||
}
|
||||
|
||||
// 单人审批
|
||||
JSONObject agreeTask = findFirstActedTask(taskList, "AGREE");
|
||||
if (agreeTask != null) {
|
||||
decision.setNodeStatus("COMPLETED");
|
||||
decision.setNodeStatusText(nodeStatusText("COMPLETED"));
|
||||
decision.setAgreed(true);
|
||||
decision.setActorUserIds(List.of(agreeTask.getString("userId")));
|
||||
decision.setOperatorTime(parseFinishTime(agreeTask.getString("finishTime")));
|
||||
return decision;
|
||||
}
|
||||
if (hasRunningOrNew(taskList)) {
|
||||
decision.setNodeStatus("RUNNING");
|
||||
decision.setNodeStatusText(nodeStatusText("RUNNING"));
|
||||
decision.setActorUserIds(listAllAssigneeIds(taskList));
|
||||
return decision;
|
||||
}
|
||||
return decisionFromPendingTasks(taskList);
|
||||
}
|
||||
|
||||
public boolean isNodeCompleted(List<JSONObject> taskList, String approvalMethod) {
|
||||
NodeTaskDecision decision = evaluateNodeTasks(taskList, approvalMethod);
|
||||
return decision.isAgreed();
|
||||
}
|
||||
|
||||
/** 审批实例是否已拒绝或终止(此时不应反写已通过环节的痕迹) */
|
||||
public boolean isInstanceRejectedOrCancelled(JSONObject instance) {
|
||||
if (instance == null) {
|
||||
return false;
|
||||
}
|
||||
String result = instance.getString("result");
|
||||
if (oConvertUtils.isNotEmpty(result) && "refuse".equalsIgnoreCase(result.trim())) {
|
||||
return true;
|
||||
}
|
||||
String status = instance.getString("status");
|
||||
if (oConvertUtils.isEmpty(status)) {
|
||||
return false;
|
||||
}
|
||||
String normalized = status.trim().toUpperCase();
|
||||
return "TERMINATED".equals(normalized) || "CANCELED".equals(normalized) || "CANCELLED".equals(normalized);
|
||||
}
|
||||
|
||||
public String nodeStatusText(String nodeStatus) {
|
||||
if (oConvertUtils.isEmpty(nodeStatus)) {
|
||||
return "未知";
|
||||
}
|
||||
return switch (nodeStatus.toUpperCase()) {
|
||||
case "COMPLETED" -> "已完成";
|
||||
case "RUNNING" -> "进行中";
|
||||
case "REFUSED" -> "已拒绝";
|
||||
case "CANCELED" -> "已取消";
|
||||
case "NEW" -> "未启动";
|
||||
default -> nodeStatus;
|
||||
};
|
||||
}
|
||||
|
||||
public List<String> resolveActorNames(List<String> dtUserIds) {
|
||||
if (dtUserIds == null || dtUserIds.isEmpty()) {
|
||||
return new ArrayList<>();
|
||||
}
|
||||
Map<String, String> nameMap = batchResolveDtUserDisplayNames(dtUserIds);
|
||||
return dtUserIds.stream().map(id -> nameMap.getOrDefault(id, id)).collect(Collectors.toList());
|
||||
}
|
||||
|
||||
private NodeTaskDecision decisionFromPendingTasks(List<JSONObject> taskList) {
|
||||
NodeTaskDecision decision = new NodeTaskDecision();
|
||||
if (hasRunningOrNew(taskList)) {
|
||||
decision.setNodeStatus("RUNNING");
|
||||
decision.setNodeStatusText(nodeStatusText("RUNNING"));
|
||||
decision.setActorUserIds(listAllAssigneeIds(taskList));
|
||||
return decision;
|
||||
}
|
||||
if (allCanceled(taskList)) {
|
||||
decision.setNodeStatus("CANCELED");
|
||||
decision.setNodeStatusText(nodeStatusText("CANCELED"));
|
||||
decision.setActorUserIds(listAllAssigneeIds(taskList));
|
||||
return decision;
|
||||
}
|
||||
decision.setNodeStatus("NEW");
|
||||
decision.setNodeStatusText(nodeStatusText("NEW"));
|
||||
decision.setActorUserIds(listAllAssigneeIds(taskList));
|
||||
return decision;
|
||||
}
|
||||
|
||||
private StageCompletion toStageCompletion(String stageKey, String activityId, NodeTaskDecision decision) {
|
||||
if (decision == null || !decision.isAgreed() || decision.getActorUserIds() == null
|
||||
|| decision.getActorUserIds().isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
List<String> names = resolveActorNames(decision.getActorUserIds());
|
||||
StageCompletion completion = new StageCompletion();
|
||||
completion.setStage(stageKey);
|
||||
completion.setActivityId(activityId);
|
||||
completion.setOperatorBy(String.join("、", names));
|
||||
completion.setOperatorTime(decision.getOperatorTime() == null ? new Date() : decision.getOperatorTime());
|
||||
completion.setDtUserIds(decision.getActorUserIds());
|
||||
return completion;
|
||||
}
|
||||
|
||||
private String normalizeApprovalMethod(String approvalMethod) {
|
||||
return oConvertUtils.isEmpty(approvalMethod) ? "NONE" : approvalMethod.trim().toUpperCase();
|
||||
}
|
||||
|
||||
private JSONObject findFirstActedTask(List<JSONObject> taskList, String result) {
|
||||
return taskList.stream()
|
||||
.filter(task -> task != null && "COMPLETED".equalsIgnoreCase(task.getString("status")))
|
||||
.filter(task -> result.equalsIgnoreCase(task.getString("result")))
|
||||
.min(Comparator.comparing(task -> {
|
||||
Date time = parseFinishTime(task.getString("finishTime"));
|
||||
return time == null ? new Date(Long.MAX_VALUE) : time;
|
||||
}))
|
||||
.orElse(null);
|
||||
}
|
||||
|
||||
private List<JSONObject> findAllActedTasks(List<JSONObject> taskList, String result) {
|
||||
List<JSONObject> list = new ArrayList<>();
|
||||
for (JSONObject task : taskList) {
|
||||
if (task == null) {
|
||||
continue;
|
||||
}
|
||||
if ("COMPLETED".equalsIgnoreCase(task.getString("status"))
|
||||
&& result.equalsIgnoreCase(task.getString("result"))) {
|
||||
list.add(task);
|
||||
}
|
||||
}
|
||||
return list;
|
||||
}
|
||||
|
||||
private List<String> extractOrderedUserIds(List<JSONObject> tasks) {
|
||||
List<String> ids = new ArrayList<>();
|
||||
for (JSONObject task : tasks) {
|
||||
String uid = task.getString("userId");
|
||||
if (oConvertUtils.isNotEmpty(uid) && !ids.contains(uid)) {
|
||||
ids.add(uid);
|
||||
}
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
|
||||
private List<String> listAllAssigneeIds(List<JSONObject> taskList) {
|
||||
List<String> ids = new ArrayList<>();
|
||||
for (JSONObject task : taskList) {
|
||||
if (task == null) {
|
||||
continue;
|
||||
}
|
||||
String uid = task.getString("userId");
|
||||
if (oConvertUtils.isNotEmpty(uid) && !ids.contains(uid)) {
|
||||
ids.add(uid);
|
||||
}
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
|
||||
private boolean hasRunningOrNew(List<JSONObject> taskList) {
|
||||
for (JSONObject task : taskList) {
|
||||
if (task == null) {
|
||||
continue;
|
||||
}
|
||||
String status = task.getString("status");
|
||||
if ("RUNNING".equalsIgnoreCase(status) || "NEW".equalsIgnoreCase(status)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private boolean allCanceled(List<JSONObject> taskList) {
|
||||
for (JSONObject task : taskList) {
|
||||
if (task == null) {
|
||||
continue;
|
||||
}
|
||||
if (!"CANCELED".equalsIgnoreCase(task.getString("status"))) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private int countActiveTasks(List<JSONObject> taskList) {
|
||||
int count = 0;
|
||||
for (JSONObject task : taskList) {
|
||||
if (task == null) {
|
||||
continue;
|
||||
}
|
||||
if (!"CANCELED".equalsIgnoreCase(task.getString("status"))) {
|
||||
count++;
|
||||
}
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
private Date latestFinishTime(List<JSONObject> tasks) {
|
||||
Date latest = null;
|
||||
for (JSONObject task : tasks) {
|
||||
Date time = parseFinishTime(task.getString("finishTime"));
|
||||
if (time != null && (latest == null || time.after(latest))) {
|
||||
latest = time;
|
||||
}
|
||||
}
|
||||
return latest;
|
||||
}
|
||||
|
||||
private boolean isTraceStage(String stageKey) {
|
||||
return oConvertUtils.isNotEmpty(stageKey) && TRACE_STAGES.contains(stageKey);
|
||||
}
|
||||
|
||||
private void collectAllApproverNodes(JSONObject node, List<JSONObject> out) {
|
||||
if (node == null) {
|
||||
return;
|
||||
}
|
||||
if ("approver".equals(node.getString("type"))) {
|
||||
out.add(node);
|
||||
}
|
||||
JSONArray branches = node.getJSONArray("conditionNodes");
|
||||
if (branches != null) {
|
||||
for (int i = 0; i < branches.size(); i++) {
|
||||
Object branch = branches.get(i);
|
||||
if (branch instanceof JSONObject branchObj) {
|
||||
collectAllApproverNodes(branchObj.getJSONObject("childNode"), out);
|
||||
}
|
||||
}
|
||||
}
|
||||
collectAllApproverNodes(node.getJSONObject("childNode"), out);
|
||||
}
|
||||
|
||||
private Date parseFinishTime(String finishTime) {
|
||||
if (oConvertUtils.isEmpty(finishTime)) {
|
||||
return null;
|
||||
}
|
||||
String[] patterns = {"yyyy-MM-dd HH:mm:ss", "yyyy-MM-dd'T'HH:mm:ss", "yyyy-MM-dd'T'HH:mm:ss.SSS'Z'"};
|
||||
for (String pattern : patterns) {
|
||||
try {
|
||||
return new SimpleDateFormat(pattern).parse(finishTime.trim());
|
||||
} catch (ParseException ignored) {
|
||||
// 尝试下一种格式
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private Map<String, String> batchResolveDtUserDisplayNames(Collection<String> dtUserIds) {
|
||||
Map<String, String> result = new HashMap<>();
|
||||
if (dtUserIds == null || dtUserIds.isEmpty()) {
|
||||
return result;
|
||||
}
|
||||
List<String> ids = dtUserIds.stream().filter(oConvertUtils::isNotEmpty).distinct().collect(Collectors.toList());
|
||||
if (ids.isEmpty()) {
|
||||
return result;
|
||||
}
|
||||
String inClause = ids.stream().map(id -> "?").collect(Collectors.joining(","));
|
||||
try {
|
||||
List<Map<String, Object>> localRows = jdbcTemplate.queryForList(
|
||||
"SELECT ding_user_id, realname, username FROM sys_user "
|
||||
+ "WHERE ding_user_id IN (" + inClause + ") AND (del_flag=0 OR del_flag IS NULL)",
|
||||
ids.toArray());
|
||||
for (Map<String, Object> row : localRows) {
|
||||
String dtId = stringValue(row.get("ding_user_id"));
|
||||
if (oConvertUtils.isEmpty(dtId)) {
|
||||
continue;
|
||||
}
|
||||
result.put(dtId, pickDisplayName(stringValue(row.get("realname")), stringValue(row.get("username")), dtId));
|
||||
}
|
||||
} catch (Exception ignored) {
|
||||
// 查询失败时降级保留钉钉ID
|
||||
}
|
||||
List<String> missing = ids.stream().filter(id -> !result.containsKey(id)).collect(Collectors.toList());
|
||||
if (!missing.isEmpty()) {
|
||||
String missingIn = missing.stream().map(id -> "?").collect(Collectors.joining(","));
|
||||
try {
|
||||
List<Map<String, Object>> thirdRows = jdbcTemplate.queryForList(
|
||||
"SELECT t.third_user_id, u.realname, u.username "
|
||||
+ "FROM sys_third_account t "
|
||||
+ "JOIN sys_user u ON u.id = t.sys_user_id "
|
||||
+ "WHERE t.third_type='dingtalk' AND t.third_user_id IN (" + missingIn + ") "
|
||||
+ "AND (t.del_flag=0 OR t.del_flag IS NULL) AND (u.del_flag=0 OR u.del_flag IS NULL)",
|
||||
missing.toArray());
|
||||
for (Map<String, Object> row : thirdRows) {
|
||||
String dtId = stringValue(row.get("third_user_id"));
|
||||
if (oConvertUtils.isEmpty(dtId) || result.containsKey(dtId)) {
|
||||
continue;
|
||||
}
|
||||
result.put(dtId, pickDisplayName(stringValue(row.get("realname")), stringValue(row.get("username")), dtId));
|
||||
}
|
||||
} catch (Exception ignored) {
|
||||
// 查询失败时降级保留钉钉ID
|
||||
}
|
||||
}
|
||||
for (String id : ids) {
|
||||
result.putIfAbsent(id, id);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private String pickDisplayName(String realname, String username, String fallback) {
|
||||
if (oConvertUtils.isNotEmpty(realname)) {
|
||||
return realname;
|
||||
}
|
||||
if (oConvertUtils.isNotEmpty(username)) {
|
||||
return username;
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
private String stringValue(Object value) {
|
||||
return value == null ? null : String.valueOf(value);
|
||||
}
|
||||
//update-end---author:GHT ---date:20260608 for:【审批注册中心】按MES multiMode解析节点状态与审批人-----------
|
||||
|
||||
@Data
|
||||
@Accessors(chain = true)
|
||||
public static class StageCompletion {
|
||||
private String stage;
|
||||
private String activityId;
|
||||
private String operatorBy;
|
||||
private Date operatorTime;
|
||||
private List<String> dtUserIds;
|
||||
}
|
||||
|
||||
@Data
|
||||
@Accessors(chain = true)
|
||||
public static class NodePair {
|
||||
private int stepNo;
|
||||
private JSONObject mesNode;
|
||||
private String activityId;
|
||||
private List<JSONObject> taskList;
|
||||
}
|
||||
|
||||
@Data
|
||||
@Accessors(chain = true)
|
||||
public static class NodeTaskDecision {
|
||||
private String nodeStatus;
|
||||
private String nodeStatusText;
|
||||
private List<String> actorUserIds = new ArrayList<>();
|
||||
private Date operatorTime;
|
||||
private boolean agreed;
|
||||
private boolean refused;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
package org.jeecg.modules.xslmes.approval.integration.engine;
|
||||
|
||||
import com.alibaba.fastjson2.JSONObject;
|
||||
import org.jeecg.common.util.oConvertUtils;
|
||||
import org.jeecg.modules.xslmes.approval.integration.entity.MesXslIntegrationAction;
|
||||
import org.jeecg.modules.xslmes.approval.integration.entity.MesXslIntegrationPlan;
|
||||
|
||||
/**
|
||||
* 集成动作 actionConfig 解析辅助。
|
||||
* 兼容向导扁平格式(stage/expectedFrom 顶层)与可视化编辑器嵌套格式(registryStage 对象)。
|
||||
*/
|
||||
public final class IntegrationActionConfigHelper {
|
||||
|
||||
private IntegrationActionConfigHelper() {
|
||||
}
|
||||
|
||||
public static String resolveStage(MesXslIntegrationAction action, MesXslIntegrationPlan plan) {
|
||||
if (action != null && oConvertUtils.isNotEmpty(action.getActionConfig())) {
|
||||
try {
|
||||
JSONObject cfg = JSONObject.parseObject(action.getActionConfig());
|
||||
String stage = cfg.getString("stage");
|
||||
if (oConvertUtils.isNotEmpty(stage)) {
|
||||
return stage.trim();
|
||||
}
|
||||
JSONObject registryStage = cfg.getJSONObject("registryStage");
|
||||
if (registryStage != null && oConvertUtils.isNotEmpty(registryStage.getString("stage"))) {
|
||||
return registryStage.getString("stage").trim();
|
||||
}
|
||||
} catch (Exception ignored) {
|
||||
// fallback
|
||||
}
|
||||
}
|
||||
if (plan != null && oConvertUtils.isNotEmpty(plan.getTriggerStage())) {
|
||||
return plan.getTriggerStage();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
public static String resolveExpectedFrom(MesXslIntegrationAction action, String stage) {
|
||||
if (action != null && 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();
|
||||
}
|
||||
JSONObject registryStage = cfg.getJSONObject("registryStage");
|
||||
if (registryStage != null && registryStage.containsKey("expectedFrom")) {
|
||||
String v = registryStage.getString("expectedFrom");
|
||||
return oConvertUtils.isEmpty(v) ? null : v.trim();
|
||||
}
|
||||
} catch (Exception ignored) {
|
||||
// fallback
|
||||
}
|
||||
}
|
||||
return RegistryStageFieldHelper.defaultExpectedFrom(stage);
|
||||
}
|
||||
|
||||
//update-begin---author:GHT ---date:20260609 for:【审批环节同步】通过后状态与审批环节解耦,业务表状态由 statusAfter 控制-----------
|
||||
/**
|
||||
* 解析环节通过后业务表应写入的状态值。
|
||||
* 未配置时回退为审批环节码(兼容旧数据)。
|
||||
*/
|
||||
public static String resolveStatusAfter(MesXslIntegrationAction action, String stage) {
|
||||
if (action != null && oConvertUtils.isNotEmpty(action.getActionConfig())) {
|
||||
try {
|
||||
JSONObject cfg = JSONObject.parseObject(action.getActionConfig());
|
||||
if (cfg.containsKey("statusAfter")) {
|
||||
String v = cfg.getString("statusAfter");
|
||||
return oConvertUtils.isEmpty(v) ? null : v.trim();
|
||||
}
|
||||
JSONObject registryStage = cfg.getJSONObject("registryStage");
|
||||
if (registryStage != null && registryStage.containsKey("statusAfter")) {
|
||||
String v = registryStage.getString("statusAfter");
|
||||
return oConvertUtils.isEmpty(v) ? null : v.trim();
|
||||
}
|
||||
} catch (Exception ignored) {
|
||||
// fallback
|
||||
}
|
||||
}
|
||||
return oConvertUtils.isNotEmpty(stage) ? stage.trim() : null;
|
||||
}
|
||||
//update-end---author:GHT ---date:20260609 for:【审批环节同步】通过后状态与审批环节解耦,业务表状态由 statusAfter 控制-----------
|
||||
|
||||
//update-begin---author:GHT ---date:20260609 for:【驳回回退】targetStage 按 containsKey 解析字典键值(含 0)-----------
|
||||
/**
|
||||
* 解析驳回回退目标:取动作配置中「回退目标」下拉所选的字典 item_value,原样写入业务表 status。
|
||||
*/
|
||||
public static String resolveTargetStage(MesXslIntegrationAction action) {
|
||||
if (action != null && oConvertUtils.isNotEmpty(action.getActionConfig())) {
|
||||
try {
|
||||
JSONObject cfg = JSONObject.parseObject(action.getActionConfig());
|
||||
if (cfg.containsKey("targetStage")) {
|
||||
String v = cfg.getString("targetStage");
|
||||
return oConvertUtils.isEmpty(v) ? null : v.trim();
|
||||
}
|
||||
JSONObject registryStage = cfg.getJSONObject("registryStage");
|
||||
if (registryStage != null && registryStage.containsKey("targetStage")) {
|
||||
String v = registryStage.getString("targetStage");
|
||||
return oConvertUtils.isEmpty(v) ? null : v.trim();
|
||||
}
|
||||
} catch (Exception ignored) {
|
||||
// fallback null
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
//update-end---author:GHT ---date:20260609 for:【驳回回退】targetStage 按 containsKey 解析字典键值(含 0)-----------
|
||||
|
||||
//update-begin---author:GHT ---date:20260610 for:【关联表痕迹同步】解析 SQL_UPDATE 动作是否同步目标表痕迹-----------
|
||||
/** 关联表动作是否开启痕迹同步(actionConfig.syncTrace) */
|
||||
public static boolean resolveSyncTrace(MesXslIntegrationAction action) {
|
||||
if (action == null || oConvertUtils.isEmpty(action.getActionConfig())) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
JSONObject cfg = JSONObject.parseObject(action.getActionConfig());
|
||||
return cfg.getBooleanValue("syncTrace");
|
||||
} catch (Exception ignored) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** 可视化配置中的目标表名 */
|
||||
public static String resolveTargetTable(MesXslIntegrationAction action) {
|
||||
if (action == null || oConvertUtils.isEmpty(action.getActionConfig())) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
JSONObject cfg = JSONObject.parseObject(action.getActionConfig());
|
||||
String table = cfg.getString("targetTable");
|
||||
return oConvertUtils.isEmpty(table) ? null : table.trim();
|
||||
} catch (Exception ignored) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** 关联条件:触发表字段 */
|
||||
public static String resolveLinkSourceField(MesXslIntegrationAction action) {
|
||||
return resolveLinkField(action, "sourceField");
|
||||
}
|
||||
|
||||
/** 关联条件:目标表字段 */
|
||||
public static String resolveLinkTargetField(MesXslIntegrationAction action) {
|
||||
return resolveLinkField(action, "targetField");
|
||||
}
|
||||
|
||||
/** 状态修改动作的新状态值(驳回回退时作为痕迹清空目标) */
|
||||
public static String resolveStatusConfigNewValue(MesXslIntegrationAction action) {
|
||||
if (action == null || oConvertUtils.isEmpty(action.getActionConfig())) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
JSONObject cfg = JSONObject.parseObject(action.getActionConfig());
|
||||
JSONObject statusConfig = cfg.getJSONObject("statusConfig");
|
||||
if (statusConfig == null) {
|
||||
return null;
|
||||
}
|
||||
String v = statusConfig.getString("newValue");
|
||||
return oConvertUtils.isEmpty(v) ? null : v.trim();
|
||||
} catch (Exception ignored) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static String resolveLinkField(MesXslIntegrationAction action, String fieldKey) {
|
||||
if (action == null || oConvertUtils.isEmpty(action.getActionConfig())) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
JSONObject cfg = JSONObject.parseObject(action.getActionConfig());
|
||||
JSONObject link = cfg.getJSONObject("linkCondition");
|
||||
if (link == null) {
|
||||
return null;
|
||||
}
|
||||
String v = link.getString(fieldKey);
|
||||
return oConvertUtils.isEmpty(v) ? null : v.trim();
|
||||
} catch (Exception ignored) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
//update-end---author:GHT ---date:20260610 for:【关联表痕迹同步】解析 SQL_UPDATE 动作是否同步目标表痕迹-----------
|
||||
}
|
||||
@@ -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】钉钉回调集成编排入口日志-----------
|
||||
}
|
||||
@@ -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<>();
|
||||
}
|
||||
@@ -0,0 +1,521 @@
|
||||
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;
|
||||
@Autowired
|
||||
private IntegrationRevertTargetResolver revertTargetResolver;
|
||||
|
||||
// ==================== 外部入口 ====================
|
||||
|
||||
/**
|
||||
* 由 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++;
|
||||
//update-begin---author:GHT ---date:20260608 for:【审核集成】动作成功后刷新源单快照,供后续动作使用最新字段-----------
|
||||
refreshSourceRecord(ctx);
|
||||
//update-end---author:GHT ---date:20260608 for:【审核集成】动作成功后刷新源单快照,供后续动作使用最新字段-----------
|
||||
} 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:20260608 for:【审核集成】多动作串行执行时刷新源单上下文-----------
|
||||
private void refreshSourceRecord(IntegrationContext ctx) {
|
||||
Map<String, Object> sourceRecord = loadSourceRecord(ctx.getSourceBizTable(), ctx.getSourceBizId());
|
||||
if (sourceRecord != null) {
|
||||
ctx.setSourceRecord(sourceRecord);
|
||||
}
|
||||
}
|
||||
//update-end---author:GHT ---date:20260608 for:【审核集成】多动作串行执行时刷新源单上下文-----------
|
||||
|
||||
//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;
|
||||
}
|
||||
// 钉钉 Stream:instanceId = 台账主键 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 target = IntegrationActionConfigHelper.resolveTargetStage(action);
|
||||
if (oConvertUtils.isNotEmpty(target)) {
|
||||
return target;
|
||||
}
|
||||
if (action != null && oConvertUtils.isNotEmpty(action.getPlanId())) {
|
||||
MesXslIntegrationPlan plan = planService.getById(action.getPlanId());
|
||||
if (plan != null && oConvertUtils.isNotEmpty(plan.getSourceTable())) {
|
||||
return revertTargetResolver.resolveRevertTarget(plan.getSourceTable());
|
||||
}
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
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 = resolveExpectedFromFromPlan(actions, plan.getTriggerStage());
|
||||
if (oConvertUtils.isEmpty(expectedFrom)) {
|
||||
return false;
|
||||
}
|
||||
return expectedFrom.equals(currentStatus);
|
||||
}
|
||||
|
||||
private String resolveExpectedFromFromPlan(List<MesXslIntegrationAction> actions, String triggerStage) {
|
||||
if (actions != null) {
|
||||
for (MesXslIntegrationAction action : actions) {
|
||||
String expectedFrom = IntegrationActionConfigHelper.resolveExpectedFrom(action, triggerStage);
|
||||
if (oConvertUtils.isNotEmpty(expectedFrom)) {
|
||||
return expectedFrom;
|
||||
}
|
||||
}
|
||||
}
|
||||
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来源回调-----------
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
package org.jeecg.modules.xslmes.approval.integration.engine;
|
||||
|
||||
import java.util.ArrayList;
|
||||
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;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.jeecg.common.util.oConvertUtils;
|
||||
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.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/**
|
||||
* 驳回回退目标解析:优先读取已发布 onReject 集成方案中的 REGISTRY_STAGE_REVERT 配置。
|
||||
*/
|
||||
@Slf4j
|
||||
@Component
|
||||
public class IntegrationRevertTargetResolver {
|
||||
|
||||
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",
|
||||
"mes_xsl_raw_material_entry", "xslmes_entry_status"
|
||||
);
|
||||
|
||||
@Autowired
|
||||
private IMesXslIntegrationPlanService planService;
|
||||
@Autowired
|
||||
private IMesXslIntegrationActionService actionService;
|
||||
@Autowired
|
||||
private IMesXslBizDocRegistryService registryService;
|
||||
@Autowired
|
||||
private JdbcTemplate jdbcTemplate;
|
||||
|
||||
//update-begin---author:GHT ---date:20260609 for:【驳回回退】从已发布 onReject 集成方案解析回退目标-----------
|
||||
/**
|
||||
* 解析业务表驳回时应回退到的 status 值。
|
||||
* 优先级:已发布 onReject 方案 REGISTRY_STAGE_REVERT.targetStage → 注册中心状态字典初始态 → compile。
|
||||
*/
|
||||
public String resolveRevertTarget(String sourceTable) {
|
||||
if (oConvertUtils.isEmpty(sourceTable)) {
|
||||
return "compile";
|
||||
}
|
||||
String fromPlan = resolveFromPublishedRejectPlan(sourceTable);
|
||||
if (oConvertUtils.isNotEmpty(fromPlan)) {
|
||||
return fromPlan;
|
||||
}
|
||||
String fromRegistry = resolveInitialStatusFromRegistry(sourceTable);
|
||||
if (oConvertUtils.isNotEmpty(fromRegistry)) {
|
||||
log.info("[集成引擎] 表 {} 未配置 onReject 回退目标,使用注册中心初始态={}", sourceTable, fromRegistry);
|
||||
return fromRegistry;
|
||||
}
|
||||
log.warn("[集成引擎] 表 {} 未解析到回退目标,回退 compile", sourceTable);
|
||||
return "compile";
|
||||
}
|
||||
|
||||
private String resolveFromPublishedRejectPlan(String sourceTable) {
|
||||
List<MesXslIntegrationPlan> plans = planService.lambdaQuery()
|
||||
.eq(MesXslIntegrationPlan::getSourceTable, sourceTable)
|
||||
.eq(MesXslIntegrationPlan::getTriggerPhase, "onReject")
|
||||
.eq(MesXslIntegrationPlan::getStatus, "1")
|
||||
.orderByAsc(MesXslIntegrationPlan::getCreateTime)
|
||||
.list();
|
||||
for (MesXslIntegrationPlan plan : plans) {
|
||||
List<MesXslIntegrationAction> actions = actionService.listByPlanId(plan.getId());
|
||||
for (MesXslIntegrationAction action : actions) {
|
||||
if (!"REGISTRY_STAGE_REVERT".equals(action.getActionType())) {
|
||||
continue;
|
||||
}
|
||||
String target = IntegrationActionConfigHelper.resolveTargetStage(action);
|
||||
if (oConvertUtils.isNotEmpty(target)) {
|
||||
return target;
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private String resolveInitialStatusFromRegistry(String sourceTable) {
|
||||
MesXslBizDocRegistry registry = registryService.findActiveByTableName(sourceTable);
|
||||
if (registry == null) {
|
||||
return null;
|
||||
}
|
||||
List<StatusDictItem> chain = loadStatusChain(registry);
|
||||
if (chain.isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
List<String> enabledStages = orderedEnabledStages(registry.getEnabledStages());
|
||||
return resolveInitialStatus(chain, enabledStages);
|
||||
}
|
||||
|
||||
private List<String> orderedEnabledStages(String enabledStages) {
|
||||
Set<String> enabled = ApprovalStageResolver.parseEnabledStages(enabledStages);
|
||||
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 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 record StatusDictItem(String value, String label) {
|
||||
}
|
||||
//update-end---author:GHT ---date:20260609 for:【驳回回退】从已发布 onReject 集成方案解析回退目标-----------
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
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();
|
||||
}
|
||||
|
||||
/** 环节默认前置状态: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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
package org.jeecg.modules.xslmes.approval.integration.engine;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Date;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
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.entity.MesXslIntegrationAction;
|
||||
import org.jeecg.modules.xslmes.approval.integration.entity.MesXslIntegrationPlan;
|
||||
import org.jeecg.modules.xslmes.approval.integration.service.IApprovalTraceSyncService;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/**
|
||||
* 关联表 SQL_UPDATE 动作执行后的审批痕迹同步/清空。
|
||||
*/
|
||||
@Slf4j
|
||||
@Component
|
||||
public class RelatedTableTraceSyncHelper {
|
||||
|
||||
@Autowired
|
||||
private IApprovalTraceSyncService approvalTraceSyncService;
|
||||
|
||||
@Autowired
|
||||
private JdbcTemplate jdbcTemplate;
|
||||
|
||||
//update-begin---author:GHT ---date:20260610 for:【关联表痕迹同步】SQL_UPDATE 成功后按动作配置同步目标表痕迹-----------
|
||||
/**
|
||||
* SQL_UPDATE 成功后,按动作配置将主表审批人/时间写入或清空关联表痕迹。
|
||||
*
|
||||
* @param affectedRows SQL 实际影响行数,零行时跳过
|
||||
*/
|
||||
public void syncAfterSqlUpdate(IntegrationContext ctx, MesXslIntegrationAction action, int affectedRows) {
|
||||
if (ctx == null || action == null || !IntegrationActionConfigHelper.resolveSyncTrace(action)) {
|
||||
return;
|
||||
}
|
||||
if (affectedRows <= 0) {
|
||||
log.info("[关联表痕迹] 跳过:SQL 零行更新 action={}", action.getActionName());
|
||||
return;
|
||||
}
|
||||
String targetTable = IntegrationActionConfigHelper.resolveTargetTable(action);
|
||||
String sourceField = IntegrationActionConfigHelper.resolveLinkSourceField(action);
|
||||
String targetField = IntegrationActionConfigHelper.resolveLinkTargetField(action);
|
||||
if (oConvertUtils.isEmpty(targetTable) || oConvertUtils.isEmpty(sourceField) || oConvertUtils.isEmpty(targetField)) {
|
||||
log.warn("[关联表痕迹] 跳过:未配置目标表或关联条件 action={}", action.getActionName());
|
||||
return;
|
||||
}
|
||||
RegistryStageFieldHelper.assertIdentifier(targetTable);
|
||||
RegistryStageFieldHelper.assertIdentifier(sourceField);
|
||||
RegistryStageFieldHelper.assertIdentifier(targetField);
|
||||
|
||||
String linkValue = resolveLinkValue(ctx, sourceField);
|
||||
if (oConvertUtils.isEmpty(linkValue)) {
|
||||
log.warn("[关联表痕迹] 跳过:触发表关联字段为空 action={} sourceField={}", action.getActionName(), sourceField);
|
||||
return;
|
||||
}
|
||||
List<String> targetIds = listTargetBizIds(targetTable, targetField, linkValue);
|
||||
if (targetIds.isEmpty()) {
|
||||
log.warn("[关联表痕迹] 跳过:未匹配到目标表记录 action={} table={} {}={}",
|
||||
action.getActionName(), targetTable, targetField, linkValue);
|
||||
return;
|
||||
}
|
||||
|
||||
if (isRejectLikePhase(ctx)) {
|
||||
syncRevertTrace(ctx, action, targetTable, targetIds);
|
||||
} else {
|
||||
syncPassTrace(ctx, action, targetTable, targetIds);
|
||||
}
|
||||
}
|
||||
|
||||
private void syncPassTrace(IntegrationContext ctx, MesXslIntegrationAction action,
|
||||
String targetTable, List<String> targetIds) {
|
||||
String stage = resolveTraceStage(ctx, action);
|
||||
if (oConvertUtils.isEmpty(stage)) {
|
||||
log.warn("[关联表痕迹] 跳过:无法解析审批环节 action={}", action.getActionName());
|
||||
return;
|
||||
}
|
||||
String stageErr = approvalTraceSyncService.checkStageAllowed(targetTable, stage);
|
||||
if (stageErr != null) {
|
||||
log.warn("[关联表痕迹] 跳过:{} action={}", stageErr, action.getActionName());
|
||||
return;
|
||||
}
|
||||
String operator = resolveOperator(ctx);
|
||||
Date operatorTime = resolveOperatorTime(ctx);
|
||||
for (String targetId : targetIds) {
|
||||
approvalTraceSyncService.syncStage(targetTable, targetId, stage, operator, operatorTime);
|
||||
}
|
||||
log.info("[关联表痕迹] 写入完成 action={} table={} stage={} operator={} count={}",
|
||||
action.getActionName(), targetTable, stage, operator, targetIds.size());
|
||||
}
|
||||
|
||||
private void syncRevertTrace(IntegrationContext ctx, MesXslIntegrationAction action,
|
||||
String targetTable, List<String> targetIds) {
|
||||
// 驳回场景取状态修改动作的「新状态」,与 SQL SET 值一致,用于痕迹回退粒度对齐
|
||||
String revertTarget = IntegrationActionConfigHelper.resolveStatusConfigNewValue(action);
|
||||
if (oConvertUtils.isEmpty(revertTarget)) {
|
||||
log.warn("[关联表痕迹] 驳回清空跳过:状态修改未配置「新状态」action={}", action.getActionName());
|
||||
return;
|
||||
}
|
||||
for (String targetId : targetIds) {
|
||||
approvalTraceSyncService.revertToStage(targetTable, targetId, revertTarget);
|
||||
}
|
||||
log.info("[关联表痕迹] 驳回清空完成 action={} table={} targetStage={} count={}",
|
||||
action.getActionName(), targetTable, revertTarget, targetIds.size());
|
||||
}
|
||||
|
||||
private boolean isRejectLikePhase(IntegrationContext ctx) {
|
||||
if (ctx.getTriggerPhase() == TriggerPhase.ON_REJECT) {
|
||||
return true;
|
||||
}
|
||||
ApprovalCallbackContext ac = ctx.getApprovalCtx();
|
||||
if (ac == null || ac.getAction() == null) {
|
||||
return false;
|
||||
}
|
||||
return ac.getAction() == ApprovalCallbackContext.Action.REJECTED
|
||||
|| ac.getAction() == ApprovalCallbackContext.Action.CANCELLED;
|
||||
}
|
||||
|
||||
private String resolveTraceStage(IntegrationContext ctx, MesXslIntegrationAction action) {
|
||||
ApprovalCallbackContext ac = ctx.getApprovalCtx();
|
||||
if (ac != null && oConvertUtils.isNotEmpty(ac.getStageKey())) {
|
||||
return ac.getStageKey().trim();
|
||||
}
|
||||
MesXslIntegrationPlan plan = ctx.getPlan();
|
||||
if (plan != null && oConvertUtils.isNotEmpty(plan.getTriggerStage())) {
|
||||
return plan.getTriggerStage().trim();
|
||||
}
|
||||
return IntegrationActionConfigHelper.resolveStage(action, plan);
|
||||
}
|
||||
|
||||
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 "系统";
|
||||
}
|
||||
|
||||
private Date resolveOperatorTime(IntegrationContext ctx) {
|
||||
ApprovalCallbackContext ac = ctx.getApprovalCtx();
|
||||
if (ac != null && ac.getOperatorTime() != null) {
|
||||
return ac.getOperatorTime();
|
||||
}
|
||||
return new Date();
|
||||
}
|
||||
|
||||
private String resolveLinkValue(IntegrationContext ctx, String sourceField) {
|
||||
if ("id".equalsIgnoreCase(sourceField)) {
|
||||
return ctx.getSourceBizId();
|
||||
}
|
||||
Map<String, Object> rec = ctx.getSourceRecord();
|
||||
if (rec == null || !rec.containsKey(sourceField)) {
|
||||
return null;
|
||||
}
|
||||
Object v = rec.get(sourceField);
|
||||
return v == null ? null : String.valueOf(v).trim();
|
||||
}
|
||||
|
||||
private List<String> listTargetBizIds(String targetTable, String targetField, String linkValue) {
|
||||
String sql = "SELECT id FROM `" + targetTable + "` WHERE `" + targetField + "` = ?";
|
||||
List<Map<String, Object>> rows = jdbcTemplate.queryForList(sql, linkValue);
|
||||
List<String> ids = new ArrayList<>();
|
||||
for (Map<String, Object> row : rows) {
|
||||
Object id = row.get("id");
|
||||
if (id != null && oConvertUtils.isNotEmpty(String.valueOf(id))) {
|
||||
ids.add(String.valueOf(id));
|
||||
}
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
//update-end---author:GHT ---date:20260610 for:【关联表痕迹同步】SQL_UPDATE 成功后按动作配置同步目标表痕迹-----------
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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_id(Phase1 预留)
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
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.IntegrationActionConfigHelper;
|
||||
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;
|
||||
|
||||
/**
|
||||
* 审批驳回回退:按集成方案 targetStage 将源单 status 回退并清空环节痕迹。
|
||||
*/
|
||||
@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);
|
||||
}
|
||||
|
||||
//update-begin---author:GHT ---date:20260609 for:【驳回回退】仅使用动作配置中「回退目标」所选字典键值-----------
|
||||
String targetStage = IntegrationActionConfigHelper.resolveTargetStage(action);
|
||||
if (oConvertUtils.isEmpty(targetStage)) {
|
||||
throw new IllegalStateException(
|
||||
"驳回回退动作未配置「回退目标」,请在集成方案动作编辑器中选择状态字典项并保存(actionConfig.targetStage)");
|
||||
}
|
||||
//update-end---author:GHT ---date:20260609 for:【驳回回退】仅使用动作配置中「回退目标」所选字典键值-----------
|
||||
|
||||
String statusField = RegistryStageFieldHelper.statusField(registry);
|
||||
RegistryStageFieldHelper.assertIdentifier(statusField);
|
||||
RegistryStageFieldHelper.assertIdentifier(bizTable);
|
||||
|
||||
//update-begin---author:GHT ---date:20260609 for:【审批注册中心】回退只重置业务表状态,操作人/时间由痕迹表承载-----------
|
||||
int affected = jdbcTemplate.update(
|
||||
"UPDATE `" + bizTable + "` SET `" + statusField + "`=? WHERE id=?",
|
||||
targetStage, bizId);
|
||||
//update-end---author:GHT ---date:20260609 for:【审批注册中心】回退只重置业务表状态,操作人/时间由痕迹表承载-----------
|
||||
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;
|
||||
}
|
||||
//update-end---author:GHT ---date:20260605 for:【XSLMES-20260605-K8R2】审批注册中心环节回退执行器-----------
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
package org.jeecg.modules.xslmes.approval.integration.engine.executor;
|
||||
|
||||
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.IntegrationActionConfigHelper;
|
||||
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);
|
||||
}
|
||||
|
||||
//update-begin---author:GHT ---date:20260609 for:【审批环节同步】审批环节仅写痕迹,业务表状态由 statusAfter 控制-----------
|
||||
String statusAfter = resolveStatusAfter(action, stage);
|
||||
if (oConvertUtils.isEmpty(statusAfter)) {
|
||||
throw new IllegalArgumentException("动作未配置通过后状态(statusAfter),且无法从审批环节推断");
|
||||
}
|
||||
//update-end---author:GHT ---date:20260609 for:【审批环节同步】审批环节仅写痕迹,业务表状态由 statusAfter 控制-----------
|
||||
|
||||
String expectedFrom = resolveExpectedFrom(action, stage);
|
||||
String statusField = RegistryStageFieldHelper.statusField(registry);
|
||||
RegistryStageFieldHelper.assertIdentifier(statusField);
|
||||
|
||||
String operator = resolveOperator(ctx);
|
||||
//update-begin---author:GHT ---date:20260608 for:【审批注册中心】环节同步使用实例tasks最新完成时间-----------
|
||||
Date now = resolveOperatorTime(ctx);
|
||||
//update-end---author:GHT ---date:20260608 for:【审批注册中心】环节同步使用实例tasks最新完成时间-----------
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
//update-begin---author:GHT ---date:20260609 for:【审批注册中心】业务表只写状态,操作人/时间统一由痕迹表承载-----------
|
||||
StringBuilder sql = new StringBuilder("UPDATE `").append(bizTable).append("` SET `")
|
||||
.append(statusField).append("`=? WHERE id=?");
|
||||
java.util.List<Object> params = new java.util.ArrayList<>();
|
||||
params.add(statusAfter);
|
||||
params.add(bizId);
|
||||
//update-end---author:GHT ---date:20260609 for:【审批注册中心】业务表只写状态,操作人/时间统一由痕迹表承载-----------
|
||||
|
||||
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={} statusAfter={} operator={}",
|
||||
bizTable, bizId, stage, statusAfter, operator);
|
||||
return "环节同步成功: " + ApprovalStageResolver.stageLabel(stage) + " → 状态=" + statusAfter;
|
||||
}
|
||||
|
||||
private String resolveStage(IntegrationContext ctx, MesXslIntegrationAction action) {
|
||||
String stage = IntegrationActionConfigHelper.resolveStage(action, ctx.getPlan());
|
||||
if (oConvertUtils.isNotEmpty(stage)) {
|
||||
return stage;
|
||||
}
|
||||
throw new IllegalArgumentException("动作未配置审批环节(stage),且方案未绑定 triggerStage");
|
||||
}
|
||||
|
||||
private String resolveExpectedFrom(MesXslIntegrationAction action, String stage) {
|
||||
return IntegrationActionConfigHelper.resolveExpectedFrom(action, stage);
|
||||
}
|
||||
|
||||
private String resolveStatusAfter(MesXslIntegrationAction action, String stage) {
|
||||
return IntegrationActionConfigHelper.resolveStatusAfter(action, 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 "系统";
|
||||
}
|
||||
|
||||
private Date resolveOperatorTime(IntegrationContext ctx) {
|
||||
ApprovalCallbackContext ac = ctx.getApprovalCtx();
|
||||
if (ac != null && ac.getOperatorTime() != null) {
|
||||
return ac.getOperatorTime();
|
||||
}
|
||||
return new Date();
|
||||
}
|
||||
//update-end---author:GHT ---date:20260605 for:【XSLMES-20260605-K8R2】审批注册中心环节同步执行器-----------
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
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.RelatedTableTraceSyncHelper;
|
||||
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;
|
||||
|
||||
@Autowired
|
||||
private RelatedTableTraceSyncHelper relatedTableTraceSyncHelper;
|
||||
|
||||
@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);
|
||||
//update-begin---author:GHT ---date:20260608 for:【审核集成】SQL_UPDATE零行时输出可诊断提示-----------
|
||||
String result = affected == 0
|
||||
? "影响行数: 0(未匹配记录,请检查关联字段、前置状态及方案绑定环节)"
|
||||
: "影响行数: " + affected;
|
||||
if (affected == 0) {
|
||||
log.warn("[集成引擎][SQL_UPDATE] 零行更新 action={} sql={}", action.getActionName(), resolvedSql);
|
||||
}
|
||||
//update-end---author:GHT ---date:20260608 for:【审核集成】SQL_UPDATE零行时输出可诊断提示-----------
|
||||
log.info("[集成引擎][SQL_UPDATE] 完成 action={} {}", action.getActionName(), result);
|
||||
//update-begin---author:GHT ---date:20260610 for:【关联表痕迹同步】SQL 成功后按动作配置写入/清空目标表痕迹-----------
|
||||
relatedTableTraceSyncHelper.syncAfterSqlUpdate(ctx, action, affected);
|
||||
//update-end---author:GHT ---date:20260610 for:【关联表痕迹同步】SQL 成功后按动作配置写入/清空目标表痕迹-----------
|
||||
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),已拒绝执行");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
package org.jeecg.modules.xslmes.approval.integration.entity;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.TableField;
|
||||
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;
|
||||
|
||||
@TableField(exist = false)
|
||||
@Schema(description = "钉钉审批实例ID(来自审批台账)")
|
||||
private String externalInstanceId;
|
||||
|
||||
@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;
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
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 = "业务状态字段名,默认 status")
|
||||
private String statusField;
|
||||
//update-end---author:GHT ---date:20260605 for:【XSLMES-20260605-K8R2】审批环节与字段映射配置-----------
|
||||
//update-begin---author:GHT ---date:20260609 for:【审批注册中心】移除操作人字段配置,操作人/时间统一由痕迹表承载,业务表只需 statusField-----------
|
||||
// proofreadByField / proofreadTimeField / auditByField / auditTimeField / approveByField / approveTimeField 已移除
|
||||
//update-end---author:GHT ---date:20260609 for:【审批注册中心】移除操作人字段配置,操作人/时间统一由痕迹表承载,业务表只需 statusField-----------
|
||||
|
||||
//update-begin---author:GHT ---date:20260608 for:【XSLMES-20260608-TRACE】列表接口路径,配置后自动注入审批痕迹字段-----------
|
||||
@Schema(description = "列表接口路径(多个逗号分隔),配置后自动注入审批痕迹字段到响应")
|
||||
private String listApiPath;
|
||||
//update-end---author:GHT ---date:20260608 for:【XSLMES-20260608-TRACE】列表接口路径,配置后自动注入审批痕迹字段-----------
|
||||
|
||||
@Schema(description = "备注")
|
||||
private String remark;
|
||||
|
||||
@Schema(description = "逻辑删除 0正常 1已删除")
|
||||
@TableLogic
|
||||
private Integer delFlag;
|
||||
|
||||
@Schema(description = "租户ID")
|
||||
private Integer tenantId;
|
||||
|
||||
@Schema(description = "所属部门编码")
|
||||
private String sysOrgCode;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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> {
|
||||
}
|
||||
@@ -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> {
|
||||
}
|
||||
@@ -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> {
|
||||
}
|
||||
@@ -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> {
|
||||
}
|
||||
@@ -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> {
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -0,0 +1,40 @@
|
||||
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)或业务 status 字典值(如 0)
|
||||
*/
|
||||
void revertToStage(String bizTable, String bizDataId, String targetStage);
|
||||
|
||||
//update-begin---author:GHT ---date:20260608 for:【审批注册中心】按实例tasks反写审批痕迹明细-----------
|
||||
/**
|
||||
* 根据钉钉审批实例 tasks 与 MES 流程节点 stageKey,反写痕迹明细及源单操作人/时间字段
|
||||
*/
|
||||
void syncFromDingInstance(String bizTable, String bizDataId, String processInstanceId, String flowConfig);
|
||||
//update-end---author:GHT ---date:20260608 for:【审批注册中心】按实例tasks反写审批痕迹明细-----------
|
||||
|
||||
//update-begin---author:GHT ---date:20260608 for:【审批注册中心】拒绝/终止时清空源单与痕迹操作人-----------
|
||||
/**
|
||||
* 驳回/终止后按 onReject 集成方案回退目标重置业务表 status 并清空痕迹(兼容旧方法名)
|
||||
*/
|
||||
void revertToCompile(String bizTable, String bizDataId);
|
||||
//update-end---author:GHT ---date:20260608 for:【审批注册中心】拒绝/终止时清空源单与痕迹操作人-----------
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
package org.jeecg.modules.xslmes.approval.integration.service;
|
||||
|
||||
import com.alibaba.fastjson2.JSONObject;
|
||||
import com.baomidou.mybatisplus.core.conditions.Wrapper;
|
||||
import com.baomidou.mybatisplus.core.metadata.IPage;
|
||||
import com.baomidou.mybatisplus.extension.service.IService;
|
||||
import org.jeecg.modules.xslmes.approval.integration.entity.MesXslApprovalTrace;
|
||||
import org.jeecg.modules.xslmes.approval.integration.vo.DingProcessForecastVO;
|
||||
import org.jeecg.modules.xslmes.approval.integration.vo.DingProcessInstanceFlowVO;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 审批痕迹明细
|
||||
*/
|
||||
public interface IMesXslApprovalTraceService extends IService<MesXslApprovalTrace> {
|
||||
|
||||
/**
|
||||
* 按业务表 + 单据ID 查询痕迹(供业务页关联展示)
|
||||
*/
|
||||
MesXslApprovalTrace getByBiz(String bizTable, String bizDataId);
|
||||
|
||||
//update-begin---author:GHT ---date:20260608 for:【XSLMES-20260608-TRACE】批量查询痕迹供响应增强器注入-----------
|
||||
/**
|
||||
* 按业务表 + 批量单据ID 查询痕迹,返回 bizDataId → trace 映射(供 ResponseBodyAdvice 批量注入)
|
||||
*/
|
||||
Map<String, MesXslApprovalTrace> batchQueryByBizIds(String bizTable, List<String> bizDataIds);
|
||||
//update-end---author:GHT ---date:20260608 for:【XSLMES-20260608-TRACE】批量查询痕迹供响应增强器注入-----------
|
||||
|
||||
//update-begin---author:GHT ---date:20260608 for:【审批注册中心】明细列表补充钉钉审批实例ID-----------
|
||||
/**
|
||||
* 分页查询并补充钉钉审批实例ID
|
||||
*/
|
||||
IPage<MesXslApprovalTrace> pageWithDingInstanceId(IPage<MesXslApprovalTrace> page, Wrapper<MesXslApprovalTrace> wrapper);
|
||||
|
||||
/**
|
||||
* 批量补充钉钉审批实例ID
|
||||
*/
|
||||
void enrichExternalInstanceIds(List<MesXslApprovalTrace> traces);
|
||||
//update-end---author:GHT ---date:20260608 for:【审批注册中心】明细列表补充钉钉审批实例ID-----------
|
||||
|
||||
//update-begin---author:GHT ---date:20260608 for:【审批注册中心】查看钉钉审批流转记录-----------
|
||||
/**
|
||||
* 按业务单据或钉钉实例ID拉取审批流转操作记录
|
||||
*/
|
||||
DingProcessInstanceFlowVO getDingFlowRecords(String bizTable, String bizDataId, String processInstanceId);
|
||||
//update-end---author:GHT ---date:20260608 for:【审批注册中心】查看钉钉审批流转记录-----------
|
||||
|
||||
//update-begin---author:GHT ---date:20260608 for:【审批注册中心】审批节点改由实例tasks按activityId解析-----------
|
||||
/**
|
||||
* 按业务单据或钉钉实例ID拉取审批实例,从 tasks 按 activityId 解析审批节点
|
||||
*/
|
||||
DingProcessForecastVO getDingProcessForecast(String bizTable, String bizDataId, String processInstanceId);
|
||||
//update-end---author:GHT ---date:20260608 for:【审批注册中心】审批节点改由实例tasks按activityId解析-----------
|
||||
|
||||
//update-begin---author:GHT ---date:20260608 for:【审批注册中心】查看钉钉审批实例原始JSON-----------
|
||||
/**
|
||||
* 按业务单据或钉钉实例ID拉取审批实例接口原始 JSON 响应
|
||||
*/
|
||||
JSONObject getDingProcessInstance(String bizTable, String bizDataId, String processInstanceId);
|
||||
//update-end---author:GHT ---date:20260608 for:【审批注册中心】查看钉钉审批实例原始JSON-----------
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -0,0 +1,787 @@
|
||||
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.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-begin---author:GHT ---date:2026-06-10 for:【审批流设计】单节点生成集成方案并返回方案ID-----------
|
||||
/**
|
||||
* 为流程设计器中当前审批节点生成(或复用)集成方案,便于生成后直接配置动作。
|
||||
*/
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public Result<Map<String, Object>> generateForNode(String sourceTable, String flowId, String nodeId,
|
||||
String stageKey, String flowConfigJson, boolean overwriteDraft) {
|
||||
if (oConvertUtils.isEmpty(sourceTable) || oConvertUtils.isEmpty(flowId) || oConvertUtils.isEmpty(nodeId)) {
|
||||
return Result.error("缺少业务表、审批流或节点信息");
|
||||
}
|
||||
if (oConvertUtils.isEmpty(stageKey)) {
|
||||
return Result.error("请先在节点上绑定审批环节(校对/审核/批准)");
|
||||
}
|
||||
MesXslBizDocRegistry registry = registryService.findActiveByTableName(sourceTable);
|
||||
if (registry == null) {
|
||||
return Result.error("业务表未在审批注册中心启用: " + sourceTable);
|
||||
}
|
||||
MesXslApprovalFlow flow = resolveFlow(sourceTable, flowId);
|
||||
String configJson = oConvertUtils.isNotEmpty(flowConfigJson) ? flowConfigJson : flow.getFlowConfig();
|
||||
if (oConvertUtils.isEmpty(configJson)) {
|
||||
return Result.error("审批流程未设计,请先保存或完成流程节点配置");
|
||||
}
|
||||
|
||||
List<String> enabledStages = orderedEnabledStages(registry);
|
||||
List<FlowNode> flowNodes = parseApproverNodes(configJson);
|
||||
boolean nodeFound = flowNodes.stream().anyMatch(n -> nodeId.equals(n.nodeId));
|
||||
if (!nodeFound) {
|
||||
return Result.error("当前节点不在流程配置中,请确认流程设计已包含该节点");
|
||||
}
|
||||
|
||||
List<StatusDictItem> statusChain = loadStatusChain(registry);
|
||||
String initialStatus = resolveInitialStatus(statusChain, enabledStages);
|
||||
Map<String, String> overrides = Map.of(nodeId, stageKey.trim());
|
||||
List<StageBinding> bindings = bindAllFlowNodes(flowNodes, registry, enabledStages, statusChain, initialStatus, overrides);
|
||||
StageBinding binding = bindings.stream()
|
||||
.filter(b -> nodeId.equals(b.nodeId))
|
||||
.findFirst()
|
||||
.orElse(null);
|
||||
if (binding == null || !binding.stageConfigured) {
|
||||
String reason = binding != null ? binding.unconfiguredReason : "节点环节未配置";
|
||||
return Result.error(reason);
|
||||
}
|
||||
|
||||
String codePrefix = planCodePrefix(registry);
|
||||
String displayName = oConvertUtils.isNotEmpty(registry.getDisplayName()) ? registry.getDisplayName() : sourceTable;
|
||||
String planCode = codePrefix + "_reg_" + binding.stage;
|
||||
String phase = "onNodeApprove";
|
||||
|
||||
MesXslIntegrationPlan existing = planService.lambdaQuery()
|
||||
.eq(MesXslIntegrationPlan::getPlanCode, planCode)
|
||||
.one();
|
||||
if (existing != null) {
|
||||
if ("0".equals(existing.getStatus()) && overwriteDraft) {
|
||||
actionService.removeByPlanId(existing.getId());
|
||||
planService.removeById(existing.getId());
|
||||
} else {
|
||||
return buildNodeGenerateResult(existing, false, phase, binding);
|
||||
}
|
||||
}
|
||||
|
||||
Map<String, Object> actionConfig = new LinkedHashMap<>();
|
||||
actionConfig.put("visualType", "REGISTRY_STAGE_SYNC");
|
||||
actionConfig.put("stage", binding.stage);
|
||||
actionConfig.put("expectedFrom", binding.expectedFrom);
|
||||
if (oConvertUtils.isNotEmpty(binding.statusAfter)) {
|
||||
actionConfig.put("statusAfter", binding.statusAfter);
|
||||
}
|
||||
|
||||
MesXslIntegrationPlan plan = new MesXslIntegrationPlan();
|
||||
plan.setPlanCode(planCode);
|
||||
plan.setPlanName(displayName + "-" + binding.stageLabel + "通过(流程生成)");
|
||||
plan.setSourceTable(sourceTable);
|
||||
plan.setRegistryId(registry.getId());
|
||||
plan.setTriggerPhase(phase);
|
||||
plan.setTriggerStage(binding.stage);
|
||||
plan.setExecMode("async");
|
||||
plan.setStatus("0");
|
||||
plan.setRemark("按审批流程节点「" + binding.nodeName + "」自动生成");
|
||||
Result<String> validate = planService.normalizeAndValidate(plan);
|
||||
if (!validate.isSuccess()) {
|
||||
return Result.error("方案校验失败: " + validate.getMessage());
|
||||
}
|
||||
planService.save(plan);
|
||||
|
||||
MesXslIntegrationAction action = new MesXslIntegrationAction();
|
||||
action.setPlanId(plan.getId());
|
||||
action.setActionName(binding.stageLabel + "环节同步");
|
||||
action.setActionType("REGISTRY_STAGE_SYNC");
|
||||
action.setActionConfig(JSON.toJSONString(actionConfig));
|
||||
action.setExecOrder(1);
|
||||
action.setOnFail("stop");
|
||||
action.setEnabled(1);
|
||||
actionService.save(action);
|
||||
|
||||
return buildNodeGenerateResult(plan, true, phase, binding);
|
||||
}
|
||||
|
||||
private Result<Map<String, Object>> buildNodeGenerateResult(MesXslIntegrationPlan plan, boolean created,
|
||||
String phase, StageBinding binding) {
|
||||
Map<String, Object> out = new LinkedHashMap<>();
|
||||
out.put("planId", plan.getId());
|
||||
out.put("planCode", plan.getPlanCode());
|
||||
out.put("planName", plan.getPlanName());
|
||||
out.put("sourceTable", plan.getSourceTable());
|
||||
out.put("triggerPhase", phase);
|
||||
out.put("triggerStage", plan.getTriggerStage());
|
||||
out.put("status", plan.getStatus());
|
||||
out.put("created", created);
|
||||
out.put("nodeName", binding.nodeName);
|
||||
out.put("stageLabel", binding.stageLabel);
|
||||
String msg = created ? "已生成集成方案,请配置动作并发布" : "该环节已有集成方案,可直接配置动作";
|
||||
return Result.OK(msg, out);
|
||||
}
|
||||
//update-end---author:GHT ---date:2026-06-10 for:【审批流设计】单节点生成集成方案并返回方案ID-----------
|
||||
//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) : "-");
|
||||
//update-begin---author:GHT ---date:20260609 for:【审批环节同步】预览与生成增加通过后状态-----------
|
||||
node.put("statusAfter", b.statusAfter);
|
||||
node.put("statusAfterLabel", oConvertUtils.isNotEmpty(b.statusAfter) ? labelOf(statusChain, b.statusAfter) : "-");
|
||||
//update-end---author:GHT ---date:20260609 for:【审批环节同步】预览与生成增加通过后状态-----------
|
||||
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);
|
||||
if (oConvertUtils.isNotEmpty(b.statusAfter)) {
|
||||
actionConfig.put("statusAfter", b.statusAfter);
|
||||
}
|
||||
|
||||
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("props");
|
||||
if (props == null) {
|
||||
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, 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;
|
||||
String statusAfter = b.stageConfigured
|
||||
? resolveStatusAfterForBinding(b, statusChain)
|
||||
: null;
|
||||
bindings.set(i, b.withExpectedFrom(expectedFrom).withStatusAfter(statusAfter));
|
||||
}
|
||||
return bindings;
|
||||
}
|
||||
|
||||
//update-begin---author:GHT ---date:20260609 for:【审批环节同步】推断通过后业务状态(字典含环节码时自动填充)-----------
|
||||
private String resolveStatusAfterForBinding(StageBinding binding, List<StatusDictItem> statusChain) {
|
||||
if (oConvertUtils.isEmpty(binding.stage)) {
|
||||
return null;
|
||||
}
|
||||
if (indexOfValue(statusChain, binding.stage) >= 0) {
|
||||
return binding.stage;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
//update-end---author:GHT ---date:20260609 for:【审批环节同步】推断通过后业务状态(字典含环节码时自动填充)-----------
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
//update-begin---author:GHT ---date:20260609 for:【审批注册中心】移除 byField 引用,操作人由痕迹表承载-----------
|
||||
private String mapFieldToStage(MesXslBizDocRegistry registry, String fieldName) {
|
||||
// byField 已移除,节点 fieldName 不再映射环节,由 stageKey 或节点名称推断
|
||||
return null;
|
||||
}
|
||||
|
||||
private boolean isStageConfigured(MesXslBizDocRegistry registry, String stage) {
|
||||
if (registry == null || oConvertUtils.isEmpty(stage)) {
|
||||
return false;
|
||||
}
|
||||
Set<String> enabled = ApprovalStageResolver.parseEnabledStages(registry.getEnabledStages());
|
||||
return enabled.contains(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) + "」未在注册中心启用";
|
||||
}
|
||||
return "环节未完整配置";
|
||||
}
|
||||
//update-end---author:GHT ---date:20260609 for:【审批注册中心】移除 byField 引用,操作人由痕迹表承载-----------
|
||||
|
||||
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, String statusAfter, boolean stageConfigured,
|
||||
String unconfiguredReason, String suggestedStage) {
|
||||
StageBinding withExpectedFrom(String expectedFrom) {
|
||||
return new StageBinding(nodeName, nodeId, stage, stageLabel, expectedFrom, statusAfter,
|
||||
stageConfigured, unconfiguredReason, suggestedStage);
|
||||
}
|
||||
|
||||
StageBinding withStatusAfter(String statusAfter) {
|
||||
return new StageBinding(nodeName, nodeId, stage, stageLabel, expectedFrom, statusAfter,
|
||||
stageConfigured, unconfiguredReason, suggestedStage);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,273 @@
|
||||
package org.jeecg.modules.xslmes.approval.integration.service.impl;
|
||||
|
||||
import com.alibaba.fastjson2.JSONObject;
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.Date;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.jeecg.common.util.oConvertUtils;
|
||||
import org.jeecg.modules.xslmes.approval.integration.engine.ApprovalInstanceStageExtractor;
|
||||
import org.jeecg.modules.xslmes.approval.integration.engine.ApprovalInstanceStageExtractor.StageCompletion;
|
||||
import org.jeecg.modules.xslmes.approval.integration.engine.IntegrationRevertTargetResolver;
|
||||
import org.jeecg.modules.xslmes.approval.integration.engine.RegistryStageFieldHelper;
|
||||
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.mapper.MesXslApprovalTraceMapper;
|
||||
import org.jeecg.modules.xslmes.approval.integration.service.IApprovalTraceSyncService;
|
||||
import org.jeecg.modules.xslmes.approval.integration.service.IMesXslBizDocRegistryService;
|
||||
import org.jeecg.modules.xslmes.dingtalk.stream.DingTalkWorkflowService;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
/**
|
||||
* 审批痕迹双写同步
|
||||
*
|
||||
* @author GHT
|
||||
* @date 2026-06-05 for:【XSLMES-20260605-K8R2】审批痕迹双写
|
||||
*/
|
||||
@Slf4j
|
||||
@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 MesXslApprovalTraceMapper traceMapper;
|
||||
|
||||
@Autowired
|
||||
private DingTalkWorkflowService dingTalkWorkflowService;
|
||||
|
||||
@Autowired
|
||||
private ApprovalInstanceStageExtractor instanceStageExtractor;
|
||||
|
||||
@Autowired
|
||||
private JdbcTemplate jdbcTemplate;
|
||||
|
||||
@Autowired
|
||||
private IntegrationRevertTargetResolver revertTargetResolver;
|
||||
|
||||
@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 = findTraceByBiz(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;
|
||||
}
|
||||
saveOrUpdateTrace(trace);
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public void revertToStage(String bizTable, String bizDataId, String targetStage) {
|
||||
MesXslApprovalTrace trace = findTraceByBiz(bizTable, bizDataId);
|
||||
if (trace == null) {
|
||||
return;
|
||||
}
|
||||
LambdaUpdateWrapper<MesXslApprovalTrace> wrapper = new LambdaUpdateWrapper<>();
|
||||
wrapper.eq(MesXslApprovalTrace::getId, trace.getId());
|
||||
//update-begin---author:GHT ---date:20260609 for:【驳回回退】业务字典回退目标(如 0/待处理)清空全部环节痕迹-----------
|
||||
if (isFullTraceClearTarget(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;
|
||||
}
|
||||
//update-end---author:GHT ---date:20260609 for:【驳回回退】业务字典回退目标(如 0/待处理)清空全部环节痕迹-----------
|
||||
traceMapper.update(null, wrapper);
|
||||
}
|
||||
|
||||
//update-begin---author:GHT ---date:20260608 for:【审批注册中心】按实例tasks反写审批痕迹明细-----------
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public void syncFromDingInstance(String bizTable, String bizDataId, String processInstanceId, String flowConfig) {
|
||||
if (oConvertUtils.isEmpty(bizTable) || oConvertUtils.isEmpty(bizDataId) || oConvertUtils.isEmpty(processInstanceId)) {
|
||||
return;
|
||||
}
|
||||
MesXslBizDocRegistry registry = findActiveRegistry(bizTable);
|
||||
if (registry == null || oConvertUtils.isEmpty(flowConfig)) {
|
||||
return;
|
||||
}
|
||||
JSONObject instance = dingTalkWorkflowService.getProcessInstance(processInstanceId);
|
||||
if (instance == null) {
|
||||
log.warn("[审批痕迹反写] 拉取审批实例失败 table={} bizId={} instanceId={}", bizTable, bizDataId, processInstanceId);
|
||||
return;
|
||||
}
|
||||
//update-begin---author:GHT ---date:20260608 for:【审批注册中心】拒绝/终止实例禁止反写已通过环节-----------
|
||||
if (instanceStageExtractor.isInstanceRejectedOrCancelled(instance)) {
|
||||
revertToCompile(bizTable, bizDataId);
|
||||
log.info("[审批痕迹反写] 实例已拒绝/终止,已清空痕迹 table={} bizId={} instanceId={}",
|
||||
bizTable, bizDataId, processInstanceId);
|
||||
return;
|
||||
}
|
||||
//update-end---author:GHT ---date:20260608 for:【审批注册中心】拒绝/终止实例禁止反写已通过环节-----------
|
||||
List<StageCompletion> completions = instanceStageExtractor.resolveCompletedStages(instance, flowConfig);
|
||||
if (completions.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
for (StageCompletion completion : completions) {
|
||||
if (completion == null || oConvertUtils.isEmpty(completion.getStage())) {
|
||||
continue;
|
||||
}
|
||||
String stageErr = checkStageAllowed(bizTable, completion.getStage());
|
||||
if (stageErr != null) {
|
||||
continue;
|
||||
}
|
||||
syncStage(bizTable, bizDataId, completion.getStage(), completion.getOperatorBy(), completion.getOperatorTime());
|
||||
//update-begin---author:GHT ---date:20260608 for:【缺陷修复-会签集成】移除补偿路径对源单的直接修改,源单状态变更由集成方案动作(RegistryStageSyncExecutor)负责-----------
|
||||
// 此处不再调用 updateBizStageFields:源单状态字段必须经集成方案动作统一变更,
|
||||
// 补偿反写(backfillTraceFromDingInstances)只负责更新审批痕迹表,
|
||||
// 避免绕过集成方案导致第二条及后续动作(如关联表 SQL_UPDATE)无法执行。
|
||||
//update-end---author:GHT ---date:20260608 for:【缺陷修复-会签集成】移除补偿路径对源单的直接修改,源单状态变更由集成方案动作(RegistryStageSyncExecutor)负责-----------
|
||||
}
|
||||
log.info("[审批痕迹反写] 完成 table={} bizId={} instanceId={} stages={}",
|
||||
bizTable, bizDataId, processInstanceId,
|
||||
completions.stream().map(StageCompletion::getStage).reduce((a, b) -> a + "," + b).orElse(""));
|
||||
}
|
||||
|
||||
//update-end---author:GHT ---date:20260608 for:【审批注册中心】按实例tasks反写审批痕迹明细-----------
|
||||
|
||||
//update-begin---author:GHT ---date:20260609 for:【审批注册中心】拒绝/终止只重置业务表状态,操作人/时间由痕迹表承载-----------
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public void revertToCompile(String bizTable, String bizDataId) {
|
||||
if (oConvertUtils.isEmpty(bizTable) || oConvertUtils.isEmpty(bizDataId)) {
|
||||
return;
|
||||
}
|
||||
//update-begin---author:GHT ---date:20260609 for:【驳回回退】补偿回退读取 onReject 集成方案 targetStage,不写死 compile-----------
|
||||
String targetStage = revertTargetResolver.resolveRevertTarget(bizTable);
|
||||
MesXslBizDocRegistry registry = findActiveRegistry(bizTable);
|
||||
if (registry != null) {
|
||||
String statusField = RegistryStageFieldHelper.statusField(registry);
|
||||
RegistryStageFieldHelper.assertIdentifier(statusField);
|
||||
RegistryStageFieldHelper.assertIdentifier(bizTable);
|
||||
jdbcTemplate.update(
|
||||
"UPDATE `" + bizTable + "` SET `" + statusField + "`=? WHERE id=?",
|
||||
targetStage, bizDataId);
|
||||
}
|
||||
revertToStage(bizTable, bizDataId, targetStage);
|
||||
log.info("[审批痕迹回退] table={} id={} targetStage={}", bizTable, bizDataId, targetStage);
|
||||
//update-end---author:GHT ---date:20260609 for:【驳回回退】补偿回退读取 onReject 集成方案 targetStage,不写死 compile-----------
|
||||
}
|
||||
//update-end---author:GHT ---date:20260609 for:【审批注册中心】拒绝/终止只重置业务表状态,操作人/时间由痕迹表承载-----------
|
||||
|
||||
private MesXslApprovalTrace findTraceByBiz(String bizTable, String bizDataId) {
|
||||
if (oConvertUtils.isEmpty(bizTable) || oConvertUtils.isEmpty(bizDataId)) {
|
||||
return null;
|
||||
}
|
||||
return traceMapper.selectOne(new LambdaQueryWrapper<MesXslApprovalTrace>()
|
||||
.eq(MesXslApprovalTrace::getBizTable, bizTable)
|
||||
.eq(MesXslApprovalTrace::getBizDataId, bizDataId)
|
||||
.last("LIMIT 1"));
|
||||
}
|
||||
|
||||
private void saveOrUpdateTrace(MesXslApprovalTrace trace) {
|
||||
if (oConvertUtils.isEmpty(trace.getId())) {
|
||||
traceMapper.insert(trace);
|
||||
} else {
|
||||
traceMapper.updateById(trace);
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
/** 回退到编制态或业务字典初始态时,清空全部审批环节痕迹 */
|
||||
private boolean isFullTraceClearTarget(String targetStage) {
|
||||
if (oConvertUtils.isEmpty(targetStage)) {
|
||||
return true;
|
||||
}
|
||||
return "compile".equals(targetStage)
|
||||
|| (!STAGE_PROOFREAD.equals(targetStage)
|
||||
&& !STAGE_AUDIT.equals(targetStage)
|
||||
&& !STAGE_APPROVE.equals(targetStage));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,603 @@
|
||||
package org.jeecg.modules.xslmes.approval.integration.service.impl;
|
||||
|
||||
import com.alibaba.fastjson2.JSONArray;
|
||||
import com.alibaba.fastjson2.JSONObject;
|
||||
import com.baomidou.mybatisplus.core.conditions.Wrapper;
|
||||
import com.baomidou.mybatisplus.core.metadata.IPage;
|
||||
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
|
||||
import org.jeecg.common.util.oConvertUtils;
|
||||
import org.jeecg.modules.xslmes.approval.constant.ApprovalRecordConstants;
|
||||
import org.jeecg.modules.xslmes.approval.entity.MesXslApprovalFlow;
|
||||
import org.jeecg.modules.xslmes.approval.entity.MesXslApprovalRecord;
|
||||
import org.jeecg.modules.xslmes.approval.service.IMesXslApprovalFlowService;
|
||||
import org.jeecg.modules.xslmes.approval.integration.engine.ApprovalInstanceStageExtractor;
|
||||
import org.jeecg.modules.xslmes.approval.integration.engine.ApprovalInstanceStageExtractor.NodePair;
|
||||
import org.jeecg.modules.xslmes.approval.integration.engine.ApprovalInstanceStageExtractor.NodeTaskDecision;
|
||||
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.IApprovalTraceSyncService;
|
||||
import org.jeecg.modules.xslmes.approval.integration.service.IMesXslApprovalTraceService;
|
||||
import org.jeecg.modules.xslmes.approval.integration.vo.DingOperationRecordVO;
|
||||
import org.jeecg.modules.xslmes.approval.integration.vo.DingProcessForecastNodeVO;
|
||||
import org.jeecg.modules.xslmes.approval.integration.vo.DingProcessForecastVO;
|
||||
import org.jeecg.modules.xslmes.approval.integration.vo.DingProcessInstanceFlowVO;
|
||||
import org.jeecg.modules.xslmes.approval.service.IMesXslApprovalRecordService;
|
||||
import org.jeecg.modules.xslmes.dingtalk.entity.MesXslDingProcessTpl;
|
||||
import org.jeecg.modules.xslmes.dingtalk.service.DingApprovalLaunchParamBuilder;
|
||||
import org.jeecg.modules.xslmes.dingtalk.service.IMesXslDingProcessTplService;
|
||||
import org.jeecg.modules.xslmes.dingtalk.stream.DingTalkWorkflowService;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collection;
|
||||
import java.util.Collections;
|
||||
import java.util.Comparator;
|
||||
import java.util.HashMap;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.function.Function;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* 审批痕迹明细
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
public class MesXslApprovalTraceServiceImpl extends ServiceImpl<MesXslApprovalTraceMapper, MesXslApprovalTrace>
|
||||
implements IMesXslApprovalTraceService {
|
||||
|
||||
@Autowired
|
||||
private IMesXslApprovalRecordService approvalRecordService;
|
||||
|
||||
@Autowired
|
||||
private DingTalkWorkflowService dingTalkWorkflowService;
|
||||
|
||||
@Autowired
|
||||
private JdbcTemplate jdbcTemplate;
|
||||
|
||||
@Autowired
|
||||
private IMesXslDingProcessTplService dingProcessTplService;
|
||||
|
||||
@Autowired
|
||||
private IMesXslApprovalFlowService approvalFlowService;
|
||||
|
||||
@Autowired
|
||||
private DingApprovalLaunchParamBuilder launchParamBuilder;
|
||||
|
||||
@Autowired
|
||||
private IApprovalTraceSyncService approvalTraceSyncService;
|
||||
|
||||
@Autowired
|
||||
private ApprovalInstanceStageExtractor instanceStageExtractor;
|
||||
|
||||
//update-begin---author:GHT ---date:20260608 for:【XSLMES-20260608-TRACE】批量查询痕迹供响应增强器注入-----------
|
||||
@Override
|
||||
public Map<String, MesXslApprovalTrace> batchQueryByBizIds(String bizTable, List<String> bizDataIds) {
|
||||
if (oConvertUtils.isEmpty(bizTable) || bizDataIds == null || bizDataIds.isEmpty()) {
|
||||
return Collections.emptyMap();
|
||||
}
|
||||
List<String> ids = bizDataIds.stream()
|
||||
.filter(oConvertUtils::isNotEmpty)
|
||||
.distinct()
|
||||
.collect(Collectors.toList());
|
||||
if (ids.isEmpty()) {
|
||||
return Collections.emptyMap();
|
||||
}
|
||||
List<MesXslApprovalTrace> traces = lambdaQuery()
|
||||
.eq(MesXslApprovalTrace::getBizTable, bizTable)
|
||||
.in(MesXslApprovalTrace::getBizDataId, ids)
|
||||
.list();
|
||||
return traces.stream().collect(
|
||||
Collectors.toMap(MesXslApprovalTrace::getBizDataId, Function.identity(), (a, b) -> a));
|
||||
}
|
||||
//update-end---author:GHT ---date:20260608 for:【XSLMES-20260608-TRACE】批量查询痕迹供响应增强器注入-----------
|
||||
|
||||
@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();
|
||||
}
|
||||
|
||||
//update-begin---author:GHT ---date:20260608 for:【审批注册中心】明细列表补充钉钉审批实例ID-----------
|
||||
@Override
|
||||
public IPage<MesXslApprovalTrace> pageWithDingInstanceId(IPage<MesXslApprovalTrace> page, Wrapper<MesXslApprovalTrace> wrapper) {
|
||||
IPage<MesXslApprovalTrace> result = page(page, wrapper);
|
||||
enrichExternalInstanceIds(result.getRecords());
|
||||
//update-begin---author:GHT ---date:20260608 for:【审批注册中心】列表补偿反写(主路径为钉钉回调→集成方案编排)-----------
|
||||
backfillTraceFromDingInstances(result.getRecords());
|
||||
//update-end---author:GHT ---date:20260608 for:【审批注册中心】列表补偿反写(主路径为钉钉回调→集成方案编排)-----------
|
||||
return result;
|
||||
}
|
||||
|
||||
//update-begin---author:GHT ---date:20260608 for:【审批注册中心】列表加载时按实例tasks反写痕迹-----------
|
||||
private void backfillTraceFromDingInstances(List<MesXslApprovalTrace> traces) {
|
||||
if (traces == null || traces.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
for (MesXslApprovalTrace trace : traces) {
|
||||
if (trace == null || oConvertUtils.isEmpty(trace.getExternalInstanceId())) {
|
||||
continue;
|
||||
}
|
||||
MesXslApprovalRecord dingRecord = findLatestDingRecord(trace.getBizTable(), trace.getBizDataId());
|
||||
if (dingRecord == null || oConvertUtils.isEmpty(dingRecord.getFlowId())) {
|
||||
continue;
|
||||
}
|
||||
MesXslApprovalFlow mesFlow = resolveMesFlow(dingRecord);
|
||||
if (mesFlow == null || oConvertUtils.isEmpty(mesFlow.getFlowConfig())) {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
//update-begin---author:GHT ---date:20260608 for:【审批注册中心】台账已拒绝/终止时优先清空痕迹-----------
|
||||
if (ApprovalRecordConstants.STATUS_REJECTED.equals(dingRecord.getStatus())
|
||||
|| ApprovalRecordConstants.STATUS_CANCELLED.equals(dingRecord.getStatus())) {
|
||||
approvalTraceSyncService.revertToCompile(trace.getBizTable(), trace.getBizDataId());
|
||||
clearTraceFields(trace);
|
||||
continue;
|
||||
}
|
||||
//update-end---author:GHT ---date:20260608 for:【审批注册中心】台账已拒绝/终止时优先清空痕迹-----------
|
||||
approvalTraceSyncService.syncFromDingInstance(
|
||||
trace.getBizTable(), trace.getBizDataId(), trace.getExternalInstanceId(), mesFlow.getFlowConfig());
|
||||
MesXslApprovalTrace latest = getByBiz(trace.getBizTable(), trace.getBizDataId());
|
||||
if (latest != null) {
|
||||
trace.setProofreadBy(latest.getProofreadBy());
|
||||
trace.setProofreadTime(latest.getProofreadTime());
|
||||
trace.setAuditBy(latest.getAuditBy());
|
||||
trace.setAuditTime(latest.getAuditTime());
|
||||
trace.setApproveBy(latest.getApproveBy());
|
||||
trace.setApproveTime(latest.getApproveTime());
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.warn("[审批痕迹反写] 列表反写失败 table={} bizId={} instanceId={}: {}",
|
||||
trace.getBizTable(), trace.getBizDataId(), trace.getExternalInstanceId(), e.getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void clearTraceFields(MesXslApprovalTrace trace) {
|
||||
if (trace == null) {
|
||||
return;
|
||||
}
|
||||
trace.setProofreadBy(null);
|
||||
trace.setProofreadTime(null);
|
||||
trace.setAuditBy(null);
|
||||
trace.setAuditTime(null);
|
||||
trace.setApproveBy(null);
|
||||
trace.setApproveTime(null);
|
||||
}
|
||||
//update-end---author:GHT ---date:20260608 for:【审批注册中心】列表加载时按实例tasks反写痕迹-----------
|
||||
|
||||
@Override
|
||||
public void enrichExternalInstanceIds(List<MesXslApprovalTrace> traces) {
|
||||
if (traces == null || traces.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
Set<String> bizDataIds = traces.stream()
|
||||
.map(MesXslApprovalTrace::getBizDataId)
|
||||
.filter(oConvertUtils::isNotEmpty)
|
||||
.collect(Collectors.toCollection(HashSet::new));
|
||||
if (bizDataIds.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
Set<String> bizTables = traces.stream()
|
||||
.map(MesXslApprovalTrace::getBizTable)
|
||||
.filter(oConvertUtils::isNotEmpty)
|
||||
.collect(Collectors.toCollection(HashSet::new));
|
||||
if (bizTables.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
List<MesXslApprovalRecord> records = approvalRecordService.lambdaQuery()
|
||||
.in(MesXslApprovalRecord::getBizTable, bizTables)
|
||||
.in(MesXslApprovalRecord::getBizDataId, bizDataIds)
|
||||
.eq(MesXslApprovalRecord::getChannel, ApprovalRecordConstants.CHANNEL_DINGTALK)
|
||||
.orderByDesc(MesXslApprovalRecord::getLaunchNo)
|
||||
.orderByDesc(MesXslApprovalRecord::getApplyTime)
|
||||
.list();
|
||||
Map<String, String> instanceIdMap = new HashMap<>();
|
||||
for (MesXslApprovalRecord record : records) {
|
||||
if (record == null || oConvertUtils.isEmpty(record.getExternalInstanceId())) {
|
||||
continue;
|
||||
}
|
||||
String key = buildBizKey(record.getBizTable(), record.getBizDataId());
|
||||
instanceIdMap.putIfAbsent(key, record.getExternalInstanceId());
|
||||
}
|
||||
for (MesXslApprovalTrace trace : traces) {
|
||||
if (trace == null) {
|
||||
continue;
|
||||
}
|
||||
String key = buildBizKey(trace.getBizTable(), trace.getBizDataId());
|
||||
trace.setExternalInstanceId(instanceIdMap.get(key));
|
||||
}
|
||||
}
|
||||
//update-end---author:GHT ---date:20260608 for:【审批注册中心】明细列表补充钉钉审批实例ID-----------
|
||||
|
||||
//update-begin---author:GHT ---date:20260608 for:【审批注册中心】查看钉钉审批流转记录-----------
|
||||
@Override
|
||||
public DingProcessInstanceFlowVO getDingFlowRecords(String bizTable, String bizDataId, String processInstanceId) {
|
||||
String instanceId = processInstanceId;
|
||||
if (oConvertUtils.isEmpty(instanceId)) {
|
||||
MesXslApprovalRecord record = findLatestDingRecord(bizTable, bizDataId);
|
||||
if (record == null || oConvertUtils.isEmpty(record.getExternalInstanceId())) {
|
||||
throw new IllegalArgumentException("未找到绑定的钉钉审批实例,请确认该单据已通过钉钉发起审批");
|
||||
}
|
||||
instanceId = record.getExternalInstanceId();
|
||||
if (oConvertUtils.isEmpty(bizTable)) {
|
||||
bizTable = record.getBizTable();
|
||||
}
|
||||
if (oConvertUtils.isEmpty(bizDataId)) {
|
||||
bizDataId = record.getBizDataId();
|
||||
}
|
||||
}
|
||||
JSONObject instance = dingTalkWorkflowService.getProcessInstance(instanceId);
|
||||
if (instance == null) {
|
||||
throw new IllegalStateException("拉取钉钉审批实例详情失败,请稍后重试");
|
||||
}
|
||||
DingProcessInstanceFlowVO vo = new DingProcessInstanceFlowVO();
|
||||
vo.setProcessInstanceId(instanceId);
|
||||
vo.setTitle(instance.getString("title"));
|
||||
vo.setStatus(instance.getString("status"));
|
||||
vo.setResult(instance.getString("result"));
|
||||
vo.setBizTable(bizTable);
|
||||
vo.setBizDataId(bizDataId);
|
||||
List<DingOperationRecordVO> operationRecords = parseOperationRecords(instance.getJSONArray("operationRecords"));
|
||||
//update-begin---author:GHT ---date:20260608 for:【审批注册中心】钉钉操作人ID映射本地用户姓名-----------
|
||||
enrichOperatorNames(operationRecords);
|
||||
//update-end---author:GHT ---date:20260608 for:【审批注册中心】钉钉操作人ID映射本地用户姓名-----------
|
||||
vo.setOperationRecords(operationRecords);
|
||||
return vo;
|
||||
}
|
||||
|
||||
private MesXslApprovalRecord findLatestDingRecord(String bizTable, String bizDataId) {
|
||||
if (oConvertUtils.isEmpty(bizTable) || oConvertUtils.isEmpty(bizDataId)) {
|
||||
return null;
|
||||
}
|
||||
return approvalRecordService.lambdaQuery()
|
||||
.eq(MesXslApprovalRecord::getBizTable, bizTable)
|
||||
.eq(MesXslApprovalRecord::getBizDataId, bizDataId)
|
||||
.eq(MesXslApprovalRecord::getChannel, ApprovalRecordConstants.CHANNEL_DINGTALK)
|
||||
.orderByDesc(MesXslApprovalRecord::getLaunchNo)
|
||||
.orderByDesc(MesXslApprovalRecord::getApplyTime)
|
||||
.last("LIMIT 1")
|
||||
.one();
|
||||
}
|
||||
|
||||
private List<DingOperationRecordVO> parseOperationRecords(JSONArray records) {
|
||||
List<DingOperationRecordVO> list = new ArrayList<>();
|
||||
if (records == null || records.isEmpty()) {
|
||||
return list;
|
||||
}
|
||||
for (int i = 0; i < records.size(); i++) {
|
||||
JSONObject rec = records.getJSONObject(i);
|
||||
if (rec == null) {
|
||||
continue;
|
||||
}
|
||||
DingOperationRecordVO item = new DingOperationRecordVO();
|
||||
item.setUserId(rec.getString("userId"));
|
||||
item.setDate(rec.getString("date"));
|
||||
item.setType(rec.getString("type"));
|
||||
item.setResult(rec.getString("result"));
|
||||
item.setRemark(rec.getString("remark"));
|
||||
item.setShowName(rec.getString("showName"));
|
||||
item.setActivityId(rec.getString("activityId"));
|
||||
JSONArray ccUserIds = rec.getJSONArray("ccUserIds");
|
||||
if (ccUserIds != null && !ccUserIds.isEmpty()) {
|
||||
List<String> ccList = new ArrayList<>();
|
||||
for (int j = 0; j < ccUserIds.size(); j++) {
|
||||
String cc = ccUserIds.getString(j);
|
||||
if (oConvertUtils.isNotEmpty(cc)) {
|
||||
ccList.add(cc);
|
||||
}
|
||||
}
|
||||
item.setCcUserIds(ccList);
|
||||
}
|
||||
JSONArray images = rec.getJSONArray("images");
|
||||
if (images != null && !images.isEmpty()) {
|
||||
List<String> imageList = new ArrayList<>();
|
||||
for (int j = 0; j < images.size(); j++) {
|
||||
String img = images.getString(j);
|
||||
if (oConvertUtils.isNotEmpty(img)) {
|
||||
imageList.add(img);
|
||||
}
|
||||
}
|
||||
item.setImages(imageList);
|
||||
}
|
||||
list.add(item);
|
||||
}
|
||||
list.sort(Comparator.comparing(DingOperationRecordVO::getDate, Comparator.nullsLast(String::compareTo)));
|
||||
return list;
|
||||
}
|
||||
|
||||
//update-begin---author:GHT ---date:20260608 for:【审批注册中心】钉钉操作人ID映射本地用户姓名-----------
|
||||
/**
|
||||
* 将 operationRecords 中的钉钉 userId 映射为本地用户姓名。
|
||||
* 查询链:sys_user.ding_user_id → sys_third_account.third_user_id → 保留原钉钉ID。
|
||||
*/
|
||||
private void enrichOperatorNames(List<DingOperationRecordVO> list) {
|
||||
if (list == null || list.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
Set<String> dtUserIds = new HashSet<>();
|
||||
for (DingOperationRecordVO item : list) {
|
||||
if (item == null) {
|
||||
continue;
|
||||
}
|
||||
if (oConvertUtils.isNotEmpty(item.getUserId())) {
|
||||
dtUserIds.add(item.getUserId());
|
||||
}
|
||||
if (item.getCcUserIds() != null) {
|
||||
for (String cc : item.getCcUserIds()) {
|
||||
if (oConvertUtils.isNotEmpty(cc)) {
|
||||
dtUserIds.add(cc);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Map<String, String> nameMap = batchResolveDtUserDisplayNames(dtUserIds);
|
||||
for (DingOperationRecordVO item : list) {
|
||||
if (item == null) {
|
||||
continue;
|
||||
}
|
||||
if (oConvertUtils.isNotEmpty(item.getUserId())) {
|
||||
item.setUserName(nameMap.getOrDefault(item.getUserId(), item.getUserId()));
|
||||
}
|
||||
if (isUnknownShowName(item.getShowName())) {
|
||||
item.setShowName(null);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private Map<String, String> batchResolveDtUserDisplayNames(Collection<String> dtUserIds) {
|
||||
Map<String, String> result = new HashMap<>();
|
||||
if (dtUserIds == null || dtUserIds.isEmpty()) {
|
||||
return result;
|
||||
}
|
||||
List<String> ids = dtUserIds.stream().filter(oConvertUtils::isNotEmpty).distinct().collect(Collectors.toList());
|
||||
if (ids.isEmpty()) {
|
||||
return result;
|
||||
}
|
||||
String inClause = ids.stream().map(id -> "?").collect(Collectors.joining(","));
|
||||
try {
|
||||
List<Map<String, Object>> localRows = jdbcTemplate.queryForList(
|
||||
"SELECT ding_user_id, realname, username FROM sys_user "
|
||||
+ "WHERE ding_user_id IN (" + inClause + ") AND (del_flag=0 OR del_flag IS NULL)",
|
||||
ids.toArray());
|
||||
for (Map<String, Object> row : localRows) {
|
||||
String dtId = stringValue(row.get("ding_user_id"));
|
||||
if (oConvertUtils.isEmpty(dtId)) {
|
||||
continue;
|
||||
}
|
||||
result.put(dtId, pickDisplayName(stringValue(row.get("realname")), stringValue(row.get("username")), dtId));
|
||||
}
|
||||
} catch (Exception ignored) {
|
||||
// 查询失败时降级保留钉钉ID
|
||||
}
|
||||
List<String> missing = ids.stream().filter(id -> !result.containsKey(id)).collect(Collectors.toList());
|
||||
if (!missing.isEmpty()) {
|
||||
String missingIn = missing.stream().map(id -> "?").collect(Collectors.joining(","));
|
||||
try {
|
||||
List<Map<String, Object>> thirdRows = jdbcTemplate.queryForList(
|
||||
"SELECT t.third_user_id, u.realname, u.username "
|
||||
+ "FROM sys_third_account t "
|
||||
+ "JOIN sys_user u ON u.id = t.sys_user_id "
|
||||
+ "WHERE t.third_type='dingtalk' AND t.third_user_id IN (" + missingIn + ") "
|
||||
+ "AND (t.del_flag=0 OR t.del_flag IS NULL) AND (u.del_flag=0 OR u.del_flag IS NULL)",
|
||||
missing.toArray());
|
||||
for (Map<String, Object> row : thirdRows) {
|
||||
String dtId = stringValue(row.get("third_user_id"));
|
||||
if (oConvertUtils.isEmpty(dtId) || result.containsKey(dtId)) {
|
||||
continue;
|
||||
}
|
||||
result.put(dtId, pickDisplayName(stringValue(row.get("realname")), stringValue(row.get("username")), dtId));
|
||||
}
|
||||
} catch (Exception ignored) {
|
||||
// 查询失败时降级保留钉钉ID
|
||||
}
|
||||
}
|
||||
for (String id : ids) {
|
||||
result.putIfAbsent(id, id);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private String pickDisplayName(String realname, String username, String fallback) {
|
||||
if (oConvertUtils.isNotEmpty(realname)) {
|
||||
return realname;
|
||||
}
|
||||
if (oConvertUtils.isNotEmpty(username)) {
|
||||
return username;
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
private boolean isUnknownShowName(String showName) {
|
||||
if (oConvertUtils.isEmpty(showName)) {
|
||||
return true;
|
||||
}
|
||||
return "UNKNOWN".equalsIgnoreCase(showName.trim());
|
||||
}
|
||||
|
||||
private String stringValue(Object value) {
|
||||
return value == null ? null : String.valueOf(value);
|
||||
}
|
||||
//update-end---author:GHT ---date:20260608 for:【审批注册中心】钉钉操作人ID映射本地用户姓名-----------
|
||||
|
||||
private String buildBizKey(String bizTable, String bizDataId) {
|
||||
return bizTable + "#" + bizDataId;
|
||||
}
|
||||
//update-end---author:GHT ---date:20260608 for:【审批注册中心】查看钉钉审批流转记录-----------
|
||||
|
||||
//update-begin---author:GHT ---date:20260608 for:【审批注册中心】审批节点改由实例tasks按activityId解析-----------
|
||||
@Override
|
||||
public DingProcessForecastVO getDingProcessForecast(String bizTable, String bizDataId, String processInstanceId) {
|
||||
MesXslApprovalRecord dingRecord = null;
|
||||
String instanceId = processInstanceId;
|
||||
if (oConvertUtils.isEmpty(instanceId)) {
|
||||
dingRecord = findLatestDingRecord(bizTable, bizDataId);
|
||||
if (dingRecord == null || oConvertUtils.isEmpty(dingRecord.getExternalInstanceId())) {
|
||||
throw new IllegalArgumentException("未找到绑定的钉钉审批实例,请确认该单据已通过钉钉发起审批");
|
||||
}
|
||||
instanceId = dingRecord.getExternalInstanceId();
|
||||
} else {
|
||||
dingRecord = approvalRecordService.lambdaQuery()
|
||||
.eq(MesXslApprovalRecord::getExternalInstanceId, instanceId)
|
||||
.eq(MesXslApprovalRecord::getChannel, ApprovalRecordConstants.CHANNEL_DINGTALK)
|
||||
.orderByDesc(MesXslApprovalRecord::getLaunchNo)
|
||||
.last("LIMIT 1")
|
||||
.one();
|
||||
if (dingRecord == null && oConvertUtils.isNotEmpty(bizTable) && oConvertUtils.isNotEmpty(bizDataId)) {
|
||||
dingRecord = findLatestDingRecord(bizTable, bizDataId);
|
||||
}
|
||||
}
|
||||
JSONObject instance = dingTalkWorkflowService.getProcessInstance(instanceId);
|
||||
if (instance == null) {
|
||||
throw new IllegalStateException("拉取钉钉审批实例详情失败,请稍后重试");
|
||||
}
|
||||
//update-begin---author:GHT ---date:20260608 for:【审批注册中心】审批节点改由实例tasks按activityId解析-----------
|
||||
MesXslApprovalFlow mesFlow = resolveMesFlow(dingRecord);
|
||||
List<DingProcessForecastNodeVO> nodes = parseInstanceTaskNodes(instance, dingRecord, mesFlow);
|
||||
//update-end---author:GHT ---date:20260608 for:【审批注册中心】审批节点改由实例tasks按activityId解析-----------
|
||||
DingProcessForecastVO vo = new DingProcessForecastVO();
|
||||
vo.setProcessInstanceId(instanceId);
|
||||
vo.setProcessCode(resolveProcessCode(dingRecord));
|
||||
vo.setTemplateName(resolveTemplateName(dingRecord));
|
||||
vo.setMesFlowName(mesFlow != null ? mesFlow.getFlowName() : (dingRecord != null ? dingRecord.getFlowName() : null));
|
||||
vo.setNodeSource("审批实例tasks按activityId解析");
|
||||
vo.setNodes(nodes);
|
||||
return vo;
|
||||
}
|
||||
|
||||
private MesXslApprovalFlow resolveMesFlow(MesXslApprovalRecord dingRecord) {
|
||||
if (dingRecord == null || oConvertUtils.isEmpty(dingRecord.getFlowId())) {
|
||||
return null;
|
||||
}
|
||||
return approvalFlowService.getById(dingRecord.getFlowId());
|
||||
}
|
||||
|
||||
private String resolveProcessCode(MesXslApprovalRecord dingRecord) {
|
||||
if (dingRecord == null || oConvertUtils.isEmpty(dingRecord.getTemplateId())) {
|
||||
return null;
|
||||
}
|
||||
MesXslDingProcessTpl tpl = dingProcessTplService.getById(dingRecord.getTemplateId());
|
||||
return tpl == null ? null : tpl.getProcessCode();
|
||||
}
|
||||
|
||||
private String resolveTemplateName(MesXslApprovalRecord dingRecord) {
|
||||
if (dingRecord == null) {
|
||||
return null;
|
||||
}
|
||||
if (oConvertUtils.isNotEmpty(dingRecord.getTemplateName())) {
|
||||
return dingRecord.getTemplateName();
|
||||
}
|
||||
if (oConvertUtils.isEmpty(dingRecord.getTemplateId())) {
|
||||
return null;
|
||||
}
|
||||
MesXslDingProcessTpl tpl = dingProcessTplService.getById(dingRecord.getTemplateId());
|
||||
return tpl == null ? null : tpl.getTplName();
|
||||
}
|
||||
|
||||
//update-begin---author:GHT ---date:20260608 for:【审批注册中心】审批节点改由实例tasks按activityId解析-----------
|
||||
/**
|
||||
* 从审批实例 tasks 按 activityId 分组解析实际审批节点,审批方式取自 MES 流程 multiMode。
|
||||
*/
|
||||
private List<DingProcessForecastNodeVO> parseInstanceTaskNodes(JSONObject instance,
|
||||
MesXslApprovalRecord dingRecord,
|
||||
MesXslApprovalFlow mesFlow) {
|
||||
String flowConfig = mesFlow == null ? null : mesFlow.getFlowConfig();
|
||||
if (oConvertUtils.isEmpty(flowConfig)) {
|
||||
return new ArrayList<>();
|
||||
}
|
||||
List<NodePair> pairs = instanceStageExtractor.alignMesNodesWithTasks(instance, flowConfig);
|
||||
if (pairs.isEmpty()) {
|
||||
return new ArrayList<>();
|
||||
}
|
||||
Map<String, String> activityNameMap = buildActivityNameMap(instance);
|
||||
List<DingProcessForecastNodeVO> nodes = new ArrayList<>();
|
||||
for (NodePair pair : pairs) {
|
||||
JSONObject mesNode = pair.getMesNode();
|
||||
String activityId = pair.getActivityId();
|
||||
List<JSONObject> taskList = pair.getTaskList();
|
||||
String approvalMethod = instanceStageExtractor.resolveApprovalMethod(mesNode);
|
||||
NodeTaskDecision decision = instanceStageExtractor.evaluateNodeTasks(taskList, approvalMethod);
|
||||
String mesNodeName = mesNode == null ? null : mesNode.getString("name");
|
||||
String activityName = firstNonEmpty(activityNameMap.get(activityId), mesNodeName, "审批节点" + pair.getStepNo());
|
||||
List<String> actionerIds = decision.getActorUserIds() == null ? new ArrayList<>() : decision.getActorUserIds();
|
||||
List<String> actionerNames = instanceStageExtractor.resolveActorNames(actionerIds);
|
||||
DingProcessForecastNodeVO node = new DingProcessForecastNodeVO();
|
||||
node.setStepNo(pair.getStepNo());
|
||||
node.setActivityId(activityId);
|
||||
node.setActivityName(activityName);
|
||||
node.setMesNodeName(mesNodeName);
|
||||
node.setActivityType("target_approval");
|
||||
node.setApprovalMethod(approvalMethod);
|
||||
node.setApprovalMethodText(instanceStageExtractor.approvalMethodText(approvalMethod));
|
||||
node.setActionerUserIds(actionerIds);
|
||||
node.setActionerNames(actionerNames);
|
||||
node.setNodeStatus(decision.getNodeStatus());
|
||||
node.setNodeStatusText(decision.getNodeStatusText());
|
||||
nodes.add(node);
|
||||
}
|
||||
return nodes;
|
||||
}
|
||||
|
||||
private Map<String, String> buildActivityNameMap(JSONObject instance) {
|
||||
Map<String, String> map = new HashMap<>();
|
||||
JSONArray records = instance.getJSONArray("operationRecords");
|
||||
if (records == null || records.isEmpty()) {
|
||||
return map;
|
||||
}
|
||||
for (int i = 0; i < records.size(); i++) {
|
||||
JSONObject rec = records.getJSONObject(i);
|
||||
if (rec == null) {
|
||||
continue;
|
||||
}
|
||||
String activityId = rec.getString("activityId");
|
||||
String showName = rec.getString("showName");
|
||||
if (oConvertUtils.isNotEmpty(activityId) && oConvertUtils.isNotEmpty(showName)
|
||||
&& !isUnknownShowName(showName) && !map.containsKey(activityId)) {
|
||||
map.put(activityId, showName);
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
private String firstNonEmpty(String... values) {
|
||||
if (values == null) {
|
||||
return null;
|
||||
}
|
||||
for (String value : values) {
|
||||
if (oConvertUtils.isNotEmpty(value)) {
|
||||
return value.trim();
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
//update-end---author:GHT ---date:20260608 for:【审批注册中心】审批节点改由实例tasks按activityId解析-----------
|
||||
|
||||
//update-begin---author:GHT ---date:20260608 for:【审批注册中心】查看钉钉审批实例原始JSON-----------
|
||||
@Override
|
||||
public JSONObject getDingProcessInstance(String bizTable, String bizDataId, String processInstanceId) {
|
||||
String instanceId = processInstanceId;
|
||||
if (oConvertUtils.isEmpty(instanceId)) {
|
||||
MesXslApprovalRecord record = findLatestDingRecord(bizTable, bizDataId);
|
||||
if (record == null || oConvertUtils.isEmpty(record.getExternalInstanceId())) {
|
||||
throw new IllegalArgumentException("未找到绑定的钉钉审批实例,请确认该单据已通过钉钉发起审批");
|
||||
}
|
||||
instanceId = record.getExternalInstanceId();
|
||||
}
|
||||
JSONObject raw = dingTalkWorkflowService.getProcessInstanceRaw(instanceId);
|
||||
if (raw == null) {
|
||||
throw new IllegalStateException("拉取钉钉审批实例详情失败,请稍后重试");
|
||||
}
|
||||
return raw;
|
||||
}
|
||||
//update-end---author:GHT ---date:20260608 for:【审批注册中心】查看钉钉审批实例原始JSON-----------
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
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"));
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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 完善");
|
||||
}
|
||||
}
|
||||
@@ -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】集成方案绑定审批注册中心环节-----------
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
package org.jeecg.modules.xslmes.approval.integration.vo;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.Data;
|
||||
import lombok.experimental.Accessors;
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 钉钉审批实例操作记录(时间轴展示)
|
||||
*/
|
||||
@Data
|
||||
@Accessors(chain = true)
|
||||
@Schema(description = "钉钉审批操作记录")
|
||||
public class DingOperationRecordVO implements Serializable {
|
||||
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
@Schema(description = "操作人钉钉userId")
|
||||
private String userId;
|
||||
|
||||
@Schema(description = "操作人姓名(本地用户映射)")
|
||||
private String userName;
|
||||
|
||||
@Schema(description = "操作时间(ISO8601)")
|
||||
private String date;
|
||||
|
||||
@Schema(description = "操作类型")
|
||||
private String type;
|
||||
|
||||
@Schema(description = "操作结果")
|
||||
private String result;
|
||||
|
||||
@Schema(description = "备注/意见")
|
||||
private String remark;
|
||||
|
||||
@Schema(description = "节点展示名称")
|
||||
private String showName;
|
||||
|
||||
@Schema(description = "节点活动ID")
|
||||
private String activityId;
|
||||
|
||||
@Schema(description = "抄送人列表")
|
||||
private List<String> ccUserIds;
|
||||
|
||||
@Schema(description = "图片URL列表")
|
||||
private List<String> images;
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
package org.jeecg.modules.xslmes.approval.integration.vo;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.Data;
|
||||
import lombok.experimental.Accessors;
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 钉钉 processForecast 预测审批节点
|
||||
*/
|
||||
@Data
|
||||
@Accessors(chain = true)
|
||||
@Schema(description = "钉钉预测审批节点")
|
||||
public class DingProcessForecastNodeVO implements Serializable {
|
||||
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
@Schema(description = "节点序号(从1开始)")
|
||||
private Integer stepNo;
|
||||
|
||||
@Schema(description = "节点活动ID")
|
||||
private String activityId;
|
||||
|
||||
@Schema(description = "节点名称")
|
||||
private String activityName;
|
||||
|
||||
@Schema(description = "MES审批流节点名称")
|
||||
private String mesNodeName;
|
||||
|
||||
@Schema(description = "节点类型(如target_select/target_approval)")
|
||||
private String activityType;
|
||||
|
||||
@Schema(description = "审批方式 NONE/AND/OR/ONE_BY_ONE")
|
||||
private String approvalMethod;
|
||||
|
||||
@Schema(description = "审批方式中文")
|
||||
private String approvalMethodText;
|
||||
|
||||
@Schema(description = "审批人钉钉userId列表")
|
||||
private List<String> actionerUserIds;
|
||||
|
||||
@Schema(description = "审批人姓名列表(本地映射)")
|
||||
private List<String> actionerNames;
|
||||
|
||||
@Schema(description = "节点状态(聚合tasks)")
|
||||
private String nodeStatus;
|
||||
|
||||
@Schema(description = "节点状态中文")
|
||||
private String nodeStatusText;
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
package org.jeecg.modules.xslmes.approval.integration.vo;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.Data;
|
||||
import lombok.experimental.Accessors;
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 钉钉审批实例 tasks 解析的审批节点结果
|
||||
*/
|
||||
@Data
|
||||
@Accessors(chain = true)
|
||||
@Schema(description = "钉钉审批节点(实例tasks解析)")
|
||||
public class DingProcessForecastVO implements Serializable {
|
||||
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
@Schema(description = "钉钉审批实例ID")
|
||||
private String processInstanceId;
|
||||
|
||||
@Schema(description = "钉钉processCode")
|
||||
private String processCode;
|
||||
|
||||
@Schema(description = "钉钉模板名称")
|
||||
private String templateName;
|
||||
|
||||
@Schema(description = "MES审批流名称")
|
||||
private String mesFlowName;
|
||||
|
||||
@Schema(description = "节点来源说明")
|
||||
private String nodeSource;
|
||||
|
||||
@Schema(description = "审批节点列表")
|
||||
private List<DingProcessForecastNodeVO> nodes;
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
package org.jeecg.modules.xslmes.approval.integration.vo;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.Data;
|
||||
import lombok.experimental.Accessors;
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 钉钉审批实例流转详情(供前端时间轴展示)
|
||||
*/
|
||||
@Data
|
||||
@Accessors(chain = true)
|
||||
@Schema(description = "钉钉审批实例流转详情")
|
||||
public class DingProcessInstanceFlowVO implements Serializable {
|
||||
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
@Schema(description = "钉钉审批实例ID")
|
||||
private String processInstanceId;
|
||||
|
||||
@Schema(description = "审批标题")
|
||||
private String title;
|
||||
|
||||
@Schema(description = "实例状态 RUNNING/COMPLETED/TERMINATED 等")
|
||||
private String status;
|
||||
|
||||
@Schema(description = "审批结果 agree/refuse 等")
|
||||
private String result;
|
||||
|
||||
@Schema(description = "业务单据ID")
|
||||
private String bizDataId;
|
||||
|
||||
@Schema(description = "业务表名")
|
||||
private String bizTable;
|
||||
|
||||
@Schema(description = "操作记录列表(时间轴)")
|
||||
private List<DingOperationRecordVO> operationRecords;
|
||||
}
|
||||
@@ -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,不触发业务级联同步,确保单据回到“可重新提交”的初始态。
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
package org.jeecg.modules.xslmes.dingtalk.callback.controller;
|
||||
|
||||
import java.util.Arrays;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
import org.jeecg.common.api.vo.Result;
|
||||
import org.jeecg.common.system.query.QueryGenerator;
|
||||
import org.jeecg.modules.xslmes.dingtalk.callback.entity.MesXslDingCallbackLog;
|
||||
import org.jeecg.modules.xslmes.dingtalk.callback.service.IMesXslDingCallbackLogService;
|
||||
|
||||
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
|
||||
import com.baomidou.mybatisplus.core.metadata.IPage;
|
||||
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import org.jeecg.common.system.base.controller.JeecgController;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import org.springframework.web.servlet.ModelAndView;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import org.jeecg.common.aspect.annotation.AutoLog;
|
||||
import org.apache.shiro.authz.annotation.RequiresPermissions;
|
||||
|
||||
/**
|
||||
* @Description: 钉钉回调日志
|
||||
* @Author: jeecg-boot
|
||||
* @Date: 2026-06-09
|
||||
* @Version: V1.0
|
||||
*/
|
||||
@Tag(name = "钉钉回调日志")
|
||||
@RestController
|
||||
@RequestMapping("/xslmes/mesXslDingCallbackLog")
|
||||
@Slf4j
|
||||
public class MesXslDingCallbackLogController extends JeecgController<MesXslDingCallbackLog, IMesXslDingCallbackLogService> {
|
||||
|
||||
@Autowired
|
||||
private IMesXslDingCallbackLogService mesXslDingCallbackLogService;
|
||||
|
||||
/**
|
||||
* 分页列表查询
|
||||
*/
|
||||
@Operation(summary = "钉钉回调日志-分页列表查询")
|
||||
@GetMapping(value = "/list")
|
||||
public Result<IPage<MesXslDingCallbackLog>> queryPageList(MesXslDingCallbackLog mesXslDingCallbackLog,
|
||||
@RequestParam(name = "pageNo", defaultValue = "1") Integer pageNo,
|
||||
@RequestParam(name = "pageSize", defaultValue = "10") Integer pageSize,
|
||||
HttpServletRequest req) {
|
||||
QueryWrapper<MesXslDingCallbackLog> queryWrapper = QueryGenerator.initQueryWrapper(mesXslDingCallbackLog, req.getParameterMap());
|
||||
Page<MesXslDingCallbackLog> page = new Page<>(pageNo, pageSize);
|
||||
IPage<MesXslDingCallbackLog> pageList = mesXslDingCallbackLogService.page(page, queryWrapper);
|
||||
return Result.OK(pageList);
|
||||
}
|
||||
|
||||
/**
|
||||
* 添加
|
||||
*/
|
||||
@AutoLog(value = "钉钉回调日志-添加")
|
||||
@Operation(summary = "钉钉回调日志-添加")
|
||||
@RequiresPermissions("xslmes:mes_xsl_ding_callback_log:add")
|
||||
@PostMapping(value = "/add")
|
||||
public Result<String> add(@RequestBody MesXslDingCallbackLog mesXslDingCallbackLog) {
|
||||
mesXslDingCallbackLogService.save(mesXslDingCallbackLog);
|
||||
return Result.OK("添加成功!");
|
||||
}
|
||||
|
||||
/**
|
||||
* 编辑
|
||||
*/
|
||||
@AutoLog(value = "钉钉回调日志-编辑")
|
||||
@Operation(summary = "钉钉回调日志-编辑")
|
||||
@RequiresPermissions("xslmes:mes_xsl_ding_callback_log:edit")
|
||||
@RequestMapping(value = "/edit", method = {RequestMethod.PUT, RequestMethod.POST})
|
||||
public Result<String> edit(@RequestBody MesXslDingCallbackLog mesXslDingCallbackLog) {
|
||||
mesXslDingCallbackLogService.updateById(mesXslDingCallbackLog);
|
||||
return Result.OK("编辑成功!");
|
||||
}
|
||||
|
||||
/**
|
||||
* 通过id删除
|
||||
*/
|
||||
@AutoLog(value = "钉钉回调日志-通过id删除")
|
||||
@Operation(summary = "钉钉回调日志-通过id删除")
|
||||
@RequiresPermissions("xslmes:mes_xsl_ding_callback_log:delete")
|
||||
@DeleteMapping(value = "/delete")
|
||||
public Result<String> delete(@RequestParam(name = "id", required = true) String id) {
|
||||
mesXslDingCallbackLogService.removeById(id);
|
||||
return Result.OK("删除成功!");
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量删除
|
||||
*/
|
||||
@AutoLog(value = "钉钉回调日志-批量删除")
|
||||
@Operation(summary = "钉钉回调日志-批量删除")
|
||||
@RequiresPermissions("xslmes:mes_xsl_ding_callback_log:deleteBatch")
|
||||
@DeleteMapping(value = "/deleteBatch")
|
||||
public Result<String> deleteBatch(@RequestParam(name = "ids", required = true) String ids) {
|
||||
this.mesXslDingCallbackLogService.removeByIds(Arrays.asList(ids.split(",")));
|
||||
return Result.OK("批量删除成功!");
|
||||
}
|
||||
|
||||
/**
|
||||
* 通过id查询
|
||||
*/
|
||||
@Operation(summary = "钉钉回调日志-通过id查询")
|
||||
@GetMapping(value = "/queryById")
|
||||
public Result<MesXslDingCallbackLog> queryById(@RequestParam(name = "id", required = true) String id) {
|
||||
MesXslDingCallbackLog mesXslDingCallbackLog = mesXslDingCallbackLogService.getById(id);
|
||||
if (mesXslDingCallbackLog == null) {
|
||||
return Result.error("未找到对应数据");
|
||||
}
|
||||
return Result.OK(mesXslDingCallbackLog);
|
||||
}
|
||||
|
||||
/**
|
||||
* 导出excel
|
||||
*/
|
||||
@RequiresPermissions("xslmes:mes_xsl_ding_callback_log:exportXls")
|
||||
@RequestMapping(value = "/exportXls")
|
||||
public ModelAndView exportXls(HttpServletRequest request, MesXslDingCallbackLog mesXslDingCallbackLog) {
|
||||
return super.exportXls(request, mesXslDingCallbackLog, MesXslDingCallbackLog.class, "钉钉回调日志");
|
||||
}
|
||||
|
||||
/**
|
||||
* 通过excel导入数据
|
||||
*/
|
||||
@RequiresPermissions("xslmes:mes_xsl_ding_callback_log:importExcel")
|
||||
@RequestMapping(value = "/importExcel", method = RequestMethod.POST)
|
||||
public Result<?> importExcel(HttpServletRequest request, HttpServletResponse response) {
|
||||
return super.importExcel(request, response, MesXslDingCallbackLog.class);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
package org.jeecg.modules.xslmes.dingtalk.callback.entity;
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.util.Date;
|
||||
import com.baomidou.mybatisplus.annotation.IdType;
|
||||
import com.baomidou.mybatisplus.annotation.TableId;
|
||||
import com.baomidou.mybatisplus.annotation.TableLogic;
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
import lombok.Data;
|
||||
import com.fasterxml.jackson.annotation.JsonFormat;
|
||||
import org.springframework.format.annotation.DateTimeFormat;
|
||||
import org.jeecgframework.poi.excel.annotation.Excel;
|
||||
import org.jeecg.common.aspect.annotation.Dict;
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.EqualsAndHashCode;
|
||||
import lombok.experimental.Accessors;
|
||||
|
||||
/**
|
||||
* @Description: 钉钉回调日志
|
||||
* @Author: jeecg-boot
|
||||
* @Date: 2026-06-09
|
||||
* @Version: V1.0
|
||||
*/
|
||||
@Data
|
||||
@TableName("mes_xsl_ding_callback_log")
|
||||
@Accessors(chain = true)
|
||||
@EqualsAndHashCode(callSuper = false)
|
||||
@Schema(description = "钉钉回调日志")
|
||||
public class MesXslDingCallbackLog implements Serializable {
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
@TableId(type = IdType.ASSIGN_ID)
|
||||
@Schema(description = "主键")
|
||||
private String id;
|
||||
|
||||
@Excel(name = "钉钉事件ID", width = 20)
|
||||
@Schema(description = "钉钉事件ID")
|
||||
private String eventId;
|
||||
|
||||
@Excel(name = "事件类型", width = 25)
|
||||
@Schema(description = "事件类型(bpms_instance_change/bpms_task_change)")
|
||||
private String eventType;
|
||||
|
||||
@Excel(name = "审批实例ID", width = 25)
|
||||
@Schema(description = "审批实例ID")
|
||||
private String processInstanceId;
|
||||
|
||||
@Excel(name = "原始推送数据", width = 50)
|
||||
@Schema(description = "原始推送数据JSON")
|
||||
private String rawData;
|
||||
|
||||
@Excel(name = "接收时间", width = 20, format = "yyyy-MM-dd HH:mm:ss")
|
||||
@JsonFormat(timezone = "GMT+8", pattern = "yyyy-MM-dd HH:mm:ss")
|
||||
@DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss")
|
||||
@Schema(description = "接收时间")
|
||||
private Date receivedTime;
|
||||
|
||||
@Excel(name = "是否已处理", width = 10, dicCode = "yn")
|
||||
@Dict(dicCode = "yn")
|
||||
@Schema(description = "是否已处理集成方案(0否1是)")
|
||||
private Integer processed;
|
||||
|
||||
@Excel(name = "处理备注", width = 40)
|
||||
@Schema(description = "处理备注")
|
||||
private String processRemark;
|
||||
|
||||
@Excel(name = "关联业务表", width = 20)
|
||||
@Schema(description = "关联业务表")
|
||||
private String bizTable;
|
||||
|
||||
@Excel(name = "关联业务记录ID", width = 20)
|
||||
@Schema(description = "关联业务记录ID")
|
||||
private String bizDataId;
|
||||
|
||||
@Excel(name = "关联审批台账ID", width = 20)
|
||||
@Schema(description = "关联审批台账ID")
|
||||
private String recordId;
|
||||
|
||||
/**创建人*/
|
||||
@Schema(description = "创建人")
|
||||
private String createBy;
|
||||
|
||||
/**创建日期*/
|
||||
@JsonFormat(timezone = "GMT+8", pattern = "yyyy-MM-dd HH:mm:ss")
|
||||
@DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss")
|
||||
@Schema(description = "创建日期")
|
||||
private Date createTime;
|
||||
|
||||
/**更新人*/
|
||||
@Schema(description = "更新人")
|
||||
private String updateBy;
|
||||
|
||||
/**更新日期*/
|
||||
@JsonFormat(timezone = "GMT+8", pattern = "yyyy-MM-dd HH:mm:ss")
|
||||
@DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss")
|
||||
@Schema(description = "更新日期")
|
||||
private Date updateTime;
|
||||
|
||||
/**逻辑删除*/
|
||||
@TableLogic
|
||||
@Schema(description = "逻辑删除 0正常 1删除")
|
||||
private Integer delFlag;
|
||||
|
||||
/**租户ID*/
|
||||
@Schema(description = "租户ID")
|
||||
private Integer tenantId;
|
||||
|
||||
/**所属部门*/
|
||||
@Schema(description = "所属部门")
|
||||
private String sysOrgCode;
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
package org.jeecg.modules.xslmes.dingtalk.callback.mapper;
|
||||
|
||||
import org.jeecg.modules.xslmes.dingtalk.callback.entity.MesXslDingCallbackLog;
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
|
||||
/**
|
||||
* @Description: 钉钉回调日志
|
||||
* @Author: jeecg-boot
|
||||
* @Date: 2026-06-09
|
||||
* @Version: V1.0
|
||||
*/
|
||||
public interface MesXslDingCallbackLogMapper extends BaseMapper<MesXslDingCallbackLog> {
|
||||
}
|
||||
@@ -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.dingtalk.callback.mapper.MesXslDingCallbackLogMapper">
|
||||
</mapper>
|
||||
@@ -0,0 +1,13 @@
|
||||
package org.jeecg.modules.xslmes.dingtalk.callback.service;
|
||||
|
||||
import org.jeecg.modules.xslmes.dingtalk.callback.entity.MesXslDingCallbackLog;
|
||||
import com.baomidou.mybatisplus.extension.service.IService;
|
||||
|
||||
/**
|
||||
* @Description: 钉钉回调日志
|
||||
* @Author: jeecg-boot
|
||||
* @Date: 2026-06-09
|
||||
* @Version: V1.0
|
||||
*/
|
||||
public interface IMesXslDingCallbackLogService extends IService<MesXslDingCallbackLog> {
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package org.jeecg.modules.xslmes.dingtalk.callback.service.impl;
|
||||
|
||||
import org.jeecg.modules.xslmes.dingtalk.callback.entity.MesXslDingCallbackLog;
|
||||
import org.jeecg.modules.xslmes.dingtalk.callback.mapper.MesXslDingCallbackLogMapper;
|
||||
import org.jeecg.modules.xslmes.dingtalk.callback.service.IMesXslDingCallbackLogService;
|
||||
import org.springframework.stereotype.Service;
|
||||
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
|
||||
|
||||
/**
|
||||
* @Description: 钉钉回调日志
|
||||
* @Author: jeecg-boot
|
||||
* @Date: 2026-06-09
|
||||
* @Version: V1.0
|
||||
*/
|
||||
@Service
|
||||
public class MesXslDingCallbackLogServiceImpl extends ServiceImpl<MesXslDingCallbackLogMapper, MesXslDingCallbackLog> implements IMesXslDingCallbackLogService {
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
package org.jeecg.modules.xslmes.dingtalk.controller;
|
||||
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import org.jeecg.common.api.vo.Result;
|
||||
import org.jeecg.modules.xslmes.dingtalk.stream.DingTalkStreamNodeConfigService;
|
||||
import org.jeecg.modules.xslmes.dingtalk.stream.DingTalkStreamSdkRunner;
|
||||
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.RestController;
|
||||
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 钉钉 Stream 节点配置辅助接口(供「第三方配置-钉钉集成」页面展示本机信息)。
|
||||
*/
|
||||
@Tag(name = "钉钉Stream配置")
|
||||
@RestController
|
||||
@RequestMapping("/xslmes/dingtalk/stream")
|
||||
public class DingTalkStreamConfigController {
|
||||
|
||||
@Autowired
|
||||
private DingTalkStreamNodeConfigService nodeConfigService;
|
||||
|
||||
//update-begin---author:GHT ---date:20260609 for:【钉钉Stream开发】第三方配置页展示本机节点信息-----------
|
||||
@Operation(summary = "获取本机 Stream 节点信息")
|
||||
@GetMapping("/nodeInfo")
|
||||
public Result<Map<String, Object>> nodeInfo() {
|
||||
Map<String, Object> data = new LinkedHashMap<>(nodeConfigService.buildNodeInfoSnapshot());
|
||||
DingTalkStreamSdkRunner.ConnectionSnapshot snap = DingTalkStreamSdkRunner.snapshot();
|
||||
data.put("streamRunning", snap.streamRunning());
|
||||
data.put("totalEvents", snap.totalEventCount());
|
||||
data.put("reconnectCount", snap.reconnectCount());
|
||||
return Result.OK(data);
|
||||
}
|
||||
//update-end---author:GHT ---date:20260609 for:【钉钉Stream开发】第三方配置页展示本机节点信息-----------
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import com.alibaba.fastjson2.JSON;
|
||||
import com.alibaba.fastjson2.JSONArray;
|
||||
import com.alibaba.fastjson2.JSONObject;
|
||||
import org.jeecg.modules.xslmes.approval.entity.MesXslApprovalFlow;
|
||||
import org.jeecg.modules.xslmes.approval.entity.MesXslApprovalRecord;
|
||||
import org.jeecg.modules.xslmes.approval.service.IMesXslApprovalFlowService;
|
||||
import org.jeecg.modules.xslmes.approval.service.IMesXslApprovalGateService;
|
||||
import org.jeecg.modules.xslmes.dingtalk.dto.DingFormComponent;
|
||||
@@ -35,7 +36,9 @@ import org.jeecg.modules.system.entity.SysThirdAccount;
|
||||
import org.jeecg.modules.system.service.ISysThirdAccountService;
|
||||
import org.jeecg.modules.system.service.impl.ThirdAppDingtalkServiceImpl;
|
||||
import org.jeecg.modules.xslmes.dingtalk.entity.MesXslDingProcessTpl;
|
||||
import org.jeecg.modules.xslmes.dingtalk.entity.MesXslDingTplBind;
|
||||
import org.jeecg.modules.xslmes.dingtalk.service.IMesXslDingProcessTplService;
|
||||
import org.jeecg.modules.xslmes.dingtalk.service.IMesXslDingTplBindService;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
@@ -79,6 +82,9 @@ public class MesXslDingProcessTplController extends JeecgController<MesXslDingPr
|
||||
|
||||
@Autowired
|
||||
private IMesXslApprovalGateService approvalGateService;
|
||||
|
||||
@Autowired
|
||||
private IMesXslDingTplBindService dingTplBindService;
|
||||
//update-end---author:GHT ---date:2026-06-04 for:【MESToDing审批配置】绑定MES审批流(审批人来源)-----------
|
||||
|
||||
@Operation(summary = "钉钉审批模板配置-分页列表查询")
|
||||
@@ -141,10 +147,59 @@ public class MesXslDingProcessTplController extends JeecgController<MesXslDingPr
|
||||
@RequiresPermissions("xslmes:mes_xsl_ding_process_tpl:edit")
|
||||
@RequestMapping(value = "/edit", method = {RequestMethod.PUT, RequestMethod.POST})
|
||||
public Result<String> edit(@RequestBody MesXslDingProcessTpl mesXslDingProcessTpl) {
|
||||
if (oConvertUtils.isEmpty(mesXslDingProcessTpl.getId())) {
|
||||
return Result.error("缺少模板ID");
|
||||
}
|
||||
//update-begin---author:GHT ---date:20260610 for:【MESToDing审批配置】MES改模板名同步到钉钉-----------
|
||||
MesXslDingProcessTpl old = mesXslDingProcessTplService.getById(mesXslDingProcessTpl.getId());
|
||||
mesXslDingProcessTplService.updateById(mesXslDingProcessTpl);
|
||||
return Result.OK("编辑成功!");
|
||||
String msg = "编辑成功!";
|
||||
if (old != null && oConvertUtils.isNotEmpty(mesXslDingProcessTpl.getTplName())) {
|
||||
String newName = mesXslDingProcessTpl.getTplName().trim();
|
||||
String oldName = oConvertUtils.isEmpty(old.getTplName()) ? "" : old.getTplName().trim();
|
||||
if (!newName.equals(oldName)) {
|
||||
refreshBindTemplateName(mesXslDingProcessTpl.getId(), newName);
|
||||
String processCode = oConvertUtils.isNotEmpty(mesXslDingProcessTpl.getProcessCode())
|
||||
? mesXslDingProcessTpl.getProcessCode() : old.getProcessCode();
|
||||
if (oConvertUtils.isNotEmpty(processCode)) {
|
||||
MesXslDingProcessTpl latest = mesXslDingProcessTplService.getById(mesXslDingProcessTpl.getId());
|
||||
if (latest != null && oConvertUtils.isEmpty(latest.getProcessCode())) {
|
||||
latest.setProcessCode(processCode);
|
||||
}
|
||||
Result<String> pushResult = pushTemplateMetaToDingtalk(latest);
|
||||
if (!pushResult.isSuccess()) {
|
||||
return Result.OK("编辑成功,但同步钉钉模板名称失败:" + pushResult.getMessage());
|
||||
}
|
||||
msg = "编辑成功,已同步钉钉模板名称";
|
||||
}
|
||||
}
|
||||
}
|
||||
return Result.OK(msg);
|
||||
//update-end---author:GHT ---date:20260610 for:【MESToDing审批配置】MES改模板名同步到钉钉-----------
|
||||
}
|
||||
|
||||
//update-begin---author:GHT ---date:20260610 for:【钉钉审批模板】操作列停用/启用,停用后业务页不显示钉钉审批按钮-----------
|
||||
@AutoLog(value = "钉钉审批模板配置-切换启用状态")
|
||||
@Operation(summary = "钉钉审批模板配置-切换启用/停用")
|
||||
@RequiresPermissions("xslmes:mes_xsl_ding_process_tpl:edit")
|
||||
@PostMapping(value = "/toggleStatus")
|
||||
public Result<String> toggleStatus(@RequestParam(name = "id") String id) {
|
||||
if (oConvertUtils.isEmpty(id)) {
|
||||
return Result.error("缺少模板ID");
|
||||
}
|
||||
MesXslDingProcessTpl tpl = mesXslDingProcessTplService.getById(id);
|
||||
if (tpl == null) {
|
||||
return Result.error("未找到对应模板");
|
||||
}
|
||||
String newStatus = "1".equals(tpl.getStatus()) ? "0" : "1";
|
||||
MesXslDingProcessTpl update = new MesXslDingProcessTpl();
|
||||
update.setId(id);
|
||||
update.setStatus(newStatus);
|
||||
mesXslDingProcessTplService.updateById(update);
|
||||
return Result.OK("1".equals(newStatus) ? "已启用" : "已停用");
|
||||
}
|
||||
//update-end---author:GHT ---date:20260610 for:【钉钉审批模板】操作列停用/启用,停用后业务页不显示钉钉审批按钮-----------
|
||||
|
||||
@AutoLog(value = "钉钉审批模板配置-通过id删除")
|
||||
@Operation(summary = "钉钉审批模板配置-通过id删除")
|
||||
@RequiresPermissions("xslmes:mes_xsl_ding_process_tpl:delete")
|
||||
@@ -197,19 +252,11 @@ public class MesXslDingProcessTplController extends JeecgController<MesXslDingPr
|
||||
return Result.error("钉钉 AccessToken 获取失败,请检查[系统配置-第三方应用]中的钉钉配置");
|
||||
}
|
||||
|
||||
// ② 获取当前用户的钉钉 userId(优先从 sys_third_account 查,其次用手机号降级)
|
||||
String dtUserId = null;
|
||||
//update-begin---author:GHT ---date:2026-06-08 for:【XSLMES-20260608】优先查本地ding_user_id,降级sys_third_account,最终降级手机号API-----------
|
||||
// ② 获取当前用户的钉钉 userId(三级降级:ding_user_id → sys_third_account → phone→API)
|
||||
int tenantId = oConvertUtils.getInt(TenantContext.getTenant(), CommonConstant.TENANT_ID_DEFAULT_VALUE);
|
||||
List<SysThirdAccount> accounts = sysThirdAccountService.listThirdUserIdByUsername(
|
||||
new String[]{loginUser.getUsername()}, "dingtalk", tenantId);
|
||||
if (accounts != null && !accounts.isEmpty() && oConvertUtils.isNotEmpty(accounts.get(0).getThirdUserId())) {
|
||||
dtUserId = accounts.get(0).getThirdUserId();
|
||||
} else if (oConvertUtils.isNotEmpty(loginUser.getPhone())) {
|
||||
Response<String> resp = JdtUserAPI.getUseridByMobile(loginUser.getPhone(), accessToken);
|
||||
if (resp.isSuccess() && oConvertUtils.isNotEmpty(resp.getResult())) {
|
||||
dtUserId = resp.getResult();
|
||||
}
|
||||
}
|
||||
String dtUserId = resolveDtUserId(loginUser.getUsername(), loginUser.getPhone(), accessToken, tenantId);
|
||||
//update-end---author:GHT ---date:2026-06-08 for:【XSLMES-20260608】优先查本地ding_user_id,降级sys_third_account,最终降级手机号API-----------
|
||||
if (oConvertUtils.isEmpty(dtUserId)) {
|
||||
return Result.error("未能获取当前用户的钉钉 userId,请先完成钉钉账号绑定或确认手机号已在企业钉钉中注册");
|
||||
}
|
||||
@@ -252,11 +299,15 @@ public class MesXslDingProcessTplController extends JeecgController<MesXslDingPr
|
||||
}
|
||||
|
||||
List<Map<String, Object>> list = new ArrayList<>();
|
||||
Map<String, String> dingNameByCode = new LinkedHashMap<>();
|
||||
if (processList != null) {
|
||||
for (int i = 0; i < processList.size(); i++) {
|
||||
JSONObject item = processList.getJSONObject(i);
|
||||
String code = item.getString("process_code");
|
||||
String name = oConvertUtils.getString(item.getString("name"), "").trim();
|
||||
if (oConvertUtils.isNotEmpty(code) && oConvertUtils.isNotEmpty(name)) {
|
||||
dingNameByCode.put(code, name);
|
||||
}
|
||||
Map<String, Object> row = new LinkedHashMap<>();
|
||||
row.put("processCode", code);
|
||||
row.put("name", name);
|
||||
@@ -270,6 +321,9 @@ public class MesXslDingProcessTplController extends JeecgController<MesXslDingPr
|
||||
list.add(row);
|
||||
}
|
||||
}
|
||||
//update-begin---author:GHT ---date:20260610 for:【MESToDing审批配置】从钉钉同步时回写已导入模板名称-----------
|
||||
syncLocalTplNamesFromDingMap(dingNameByCode);
|
||||
//update-end---author:GHT ---date:20260610 for:【MESToDing审批配置】从钉钉同步时回写已导入模板名称-----------
|
||||
//update-end---author:GHT ---date:2026-06-04 for:【MESToDing审批配置】同步列表:已导入判定含 processCode 与同名本地草稿-----
|
||||
return Result.OK(list);
|
||||
} catch (Exception e) {
|
||||
@@ -365,6 +419,9 @@ public class MesXslDingProcessTplController extends JeecgController<MesXslDingPr
|
||||
detail.put("schemaError", "AccessToken 获取失败,请检查钉钉应用配置");
|
||||
return Result.OK(detail);
|
||||
}
|
||||
if (oConvertUtils.isEmpty(tpl.getProcessCode())) {
|
||||
return Result.OK(detail);
|
||||
}
|
||||
try {
|
||||
String url = "https://api.dingtalk.com/v1.0/workflow/forms/schemas/processCodes"
|
||||
+ "?processCode=" + tpl.getProcessCode();
|
||||
@@ -380,6 +437,7 @@ public class MesXslDingProcessTplController extends JeecgController<MesXslDingPr
|
||||
JSONObject ddResp = JSONObject.parseObject(respBody);
|
||||
if (ddResp.containsKey("code")) {
|
||||
detail.put("schemaError", ddResp.getString("message") + " (code=" + ddResp.getString("code") + ")");
|
||||
mergeDingTemplateName(detail, tpl, accessToken, null, ddResp);
|
||||
return Result.OK(detail);
|
||||
}
|
||||
|
||||
@@ -493,9 +551,11 @@ public class MesXslDingProcessTplController extends JeecgController<MesXslDingPr
|
||||
}
|
||||
detail.put("dingFields", dingFields);
|
||||
detail.put("dingFieldsCount", dingFields.size());
|
||||
mergeDingTemplateName(detail, tpl, accessToken, root, ddResp);
|
||||
} catch (Exception e) {
|
||||
log.warn("钉钉 Schema 接口异常 processCode={}: {}", tpl.getProcessCode(), e.getMessage());
|
||||
detail.put("schemaError", "接口异常: " + e.getMessage());
|
||||
mergeDingTemplateName(detail, tpl, accessToken, null, null);
|
||||
}
|
||||
return Result.OK(detail);
|
||||
}
|
||||
@@ -578,38 +638,15 @@ public class MesXslDingProcessTplController extends JeecgController<MesXslDingPr
|
||||
if (tpl == null) return Result.error("未找到对应模板配置");
|
||||
if (oConvertUtils.isEmpty(tpl.getProcessCode())) return Result.error("该记录尚无 processCode,请先创建模板");
|
||||
|
||||
String accessToken = dingtalkService.getAccessToken();
|
||||
if (oConvertUtils.isEmpty(accessToken)) return Result.error("AccessToken 获取失败");
|
||||
|
||||
List<DingFormComponent> components = buildFormComponentList(tpl.getFormFields());
|
||||
if (components.isEmpty()) return Result.error("请先在字段映射中配置至少一个字段");
|
||||
|
||||
// 带 processCode → 钉钉更新已有模板(官方文档:POST同一接口,有processCode=更新)
|
||||
DingFormUpdateRequest req = new DingFormUpdateRequest()
|
||||
.setProcessCode(tpl.getProcessCode())
|
||||
.setName(tpl.getTplName())
|
||||
.setDescription(oConvertUtils.isNotEmpty(tpl.getRemark()) ? tpl.getRemark() : "")
|
||||
.setFormComponents(components);
|
||||
|
||||
try {
|
||||
String reqJson = JSON.toJSONString(req);
|
||||
log.info("【钉钉更新模板】processCode={} 请求体: {}", tpl.getProcessCode(), reqJson);
|
||||
String respBody = callDingApi("POST", "https://api.dingtalk.com/v1.0/workflow/forms", accessToken, reqJson);
|
||||
JSONObject resp = JSONObject.parseObject(respBody);
|
||||
log.info("【钉钉更新模板】响应: {}", respBody);
|
||||
|
||||
if (resp.containsKey("code")) {
|
||||
return Result.error("钉钉返回错误: " + resp.getString("message") + " (code=" + resp.getString("code") + ")");
|
||||
}
|
||||
|
||||
Map<String, Object> result = new LinkedHashMap<>();
|
||||
result.put("processCode", tpl.getProcessCode());
|
||||
result.put("rawResponse", resp);
|
||||
return Result.OK("钉钉审批模板更新成功", result);
|
||||
} catch (Exception e) {
|
||||
log.error("更新钉钉模板异常", e);
|
||||
return Result.error("请求异常: " + e.getMessage());
|
||||
//update-begin---author:GHT ---date:20260610 for:【MESToDing审批配置】更新钉钉模板复用统一推送逻辑-----------
|
||||
Result<String> pushResult = pushTemplateMetaToDingtalk(tpl);
|
||||
if (!pushResult.isSuccess()) {
|
||||
return Result.error(pushResult.getMessage());
|
||||
}
|
||||
Map<String, Object> result = new LinkedHashMap<>();
|
||||
result.put("processCode", tpl.getProcessCode());
|
||||
return Result.OK("钉钉审批模板更新成功", result);
|
||||
//update-end---author:GHT ---date:20260610 for:【MESToDing审批配置】更新钉钉模板复用统一推送逻辑-----------
|
||||
}
|
||||
//update-end---author:GHT ---date:2026-06-03 for:【MESToDing审批配置】创建/更新钉钉审批模板-----
|
||||
|
||||
@@ -858,6 +895,184 @@ public class MesXslDingProcessTplController extends JeecgController<MesXslDingPr
|
||||
return mesXslDingProcessTplService.getOne(qw, false);
|
||||
}
|
||||
|
||||
//update-begin---author:GHT ---date:20260610 for:【MESToDing审批配置】模板名称 MES↔钉钉 双向同步-----------
|
||||
/** 将本地模板名称写入 tpl 表,并同步绑定表 template_name */
|
||||
private void applyLocalTplName(String tplId, String tplName) {
|
||||
if (oConvertUtils.isEmpty(tplId) || oConvertUtils.isEmpty(tplName)) {
|
||||
return;
|
||||
}
|
||||
String name = tplName.trim();
|
||||
MesXslDingProcessTpl update = new MesXslDingProcessTpl();
|
||||
update.setId(tplId);
|
||||
update.setTplName(name);
|
||||
mesXslDingProcessTplService.updateById(update);
|
||||
refreshBindTemplateName(tplId, name);
|
||||
}
|
||||
|
||||
/** 同步更新模板绑定表中的 template_name */
|
||||
private void refreshBindTemplateName(String templateId, String tplName) {
|
||||
if (oConvertUtils.isEmpty(templateId)) {
|
||||
return;
|
||||
}
|
||||
MesXslDingTplBind bindUpdate = new MesXslDingTplBind();
|
||||
bindUpdate.setTemplateName(tplName);
|
||||
dingTplBindService.update(bindUpdate, new QueryWrapper<MesXslDingTplBind>().eq("template_id", templateId));
|
||||
}
|
||||
|
||||
/** 从钉钉拉取模板名称并回写本地(getTemplateDetail / 设计器打开时) */
|
||||
private void mergeDingTemplateName(Map<String, Object> detail, MesXslDingProcessTpl tpl,
|
||||
String accessToken, JSONObject schemaRoot, JSONObject schemaResp) {
|
||||
if (tpl == null || oConvertUtils.isEmpty(tpl.getProcessCode())) {
|
||||
detail.put("dingNameSynced", false);
|
||||
return;
|
||||
}
|
||||
String dingName = extractDingTemplateName(schemaRoot);
|
||||
if (oConvertUtils.isEmpty(dingName)) {
|
||||
dingName = extractDingTemplateName(schemaResp);
|
||||
}
|
||||
if (oConvertUtils.isEmpty(dingName)) {
|
||||
dingName = fetchDingTemplateNameByProcessCode(tpl.getProcessCode(), accessToken);
|
||||
}
|
||||
detail.put("dingTplName", oConvertUtils.isNotEmpty(dingName) ? dingName : tpl.getTplName());
|
||||
String localName = oConvertUtils.isEmpty(tpl.getTplName()) ? "" : tpl.getTplName().trim();
|
||||
if (oConvertUtils.isNotEmpty(dingName) && !dingName.equals(localName)) {
|
||||
applyLocalTplName(tpl.getId(), dingName);
|
||||
detail.put("tplName", dingName);
|
||||
detail.put("dingNameSynced", true);
|
||||
} else {
|
||||
detail.put("dingNameSynced", false);
|
||||
}
|
||||
}
|
||||
|
||||
/** 按 processCode 批量回写本地模板名称(从钉钉同步列表) */
|
||||
private void syncLocalTplNamesFromDingMap(Map<String, String> dingNameByCode) {
|
||||
if (dingNameByCode == null || dingNameByCode.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
for (MesXslDingProcessTpl local : mesXslDingProcessTplService.list()) {
|
||||
if (oConvertUtils.isEmpty(local.getProcessCode())) {
|
||||
continue;
|
||||
}
|
||||
String dingName = dingNameByCode.get(local.getProcessCode());
|
||||
if (oConvertUtils.isEmpty(dingName)) {
|
||||
continue;
|
||||
}
|
||||
String localName = oConvertUtils.isEmpty(local.getTplName()) ? "" : local.getTplName().trim();
|
||||
if (!dingName.equals(localName)) {
|
||||
applyLocalTplName(local.getId(), dingName);
|
||||
log.info("【模板名称同步】processCode={} 本地「{}」→ 钉钉「{}」", local.getProcessCode(), localName, dingName);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 从钉钉 JSON 响应中提取模板名称 */
|
||||
private String extractDingTemplateName(JSONObject obj) {
|
||||
if (obj == null) {
|
||||
return null;
|
||||
}
|
||||
for (String key : Arrays.asList("name", "processName", "flowTitle", "title", "templateName")) {
|
||||
String val = obj.getString(key);
|
||||
if (oConvertUtils.isNotEmpty(val)) {
|
||||
return val.trim();
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** 通过可见审批列表按 processCode 查找钉钉模板名称 */
|
||||
private String fetchDingTemplateNameByProcessCode(String processCode, String accessToken) {
|
||||
if (oConvertUtils.isEmpty(processCode) || oConvertUtils.isEmpty(accessToken)) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
LoginUser loginUser = (LoginUser) SecurityUtils.getSubject().getPrincipal();
|
||||
int tenantId = oConvertUtils.getInt(TenantContext.getTenant(), CommonConstant.TENANT_ID_DEFAULT_VALUE);
|
||||
String dtUserId = resolveDtUserId(loginUser.getUsername(), loginUser.getPhone(), accessToken, tenantId);
|
||||
if (oConvertUtils.isEmpty(dtUserId)) {
|
||||
return null;
|
||||
}
|
||||
Map<String, String> nameMap = fetchVisibleProcessNameMap(dtUserId, accessToken);
|
||||
return nameMap.get(processCode);
|
||||
} catch (Exception e) {
|
||||
log.warn("按 processCode 拉取钉钉模板名称失败 processCode={}: {}", processCode, e.getMessage());
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** 拉取当前用户可见的钉钉审批模板 processCode → name 映射 */
|
||||
private Map<String, String> fetchVisibleProcessNameMap(String dtUserId, String accessToken) throws Exception {
|
||||
Map<String, String> map = new LinkedHashMap<>();
|
||||
String url = "https://oapi.dingtalk.com/topapi/process/listbyuserid?access_token=" + accessToken;
|
||||
JSONObject reqBody = new JSONObject();
|
||||
reqBody.put("userid", dtUserId);
|
||||
reqBody.put("offset", 0);
|
||||
reqBody.put("size", 100);
|
||||
HttpClient httpClient = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(10)).build();
|
||||
HttpRequest httpReq = HttpRequest.newBuilder()
|
||||
.uri(URI.create(url))
|
||||
.header("Content-Type", "application/json")
|
||||
.POST(HttpRequest.BodyPublishers.ofString(reqBody.toJSONString()))
|
||||
.timeout(Duration.ofSeconds(10))
|
||||
.build();
|
||||
String respBody = httpClient.send(httpReq, HttpResponse.BodyHandlers.ofString()).body();
|
||||
JSONObject ddResp = JSONObject.parseObject(respBody);
|
||||
if (ddResp.getIntValue("errcode") != 0) {
|
||||
return map;
|
||||
}
|
||||
JSONObject result = ddResp.getJSONObject("result");
|
||||
JSONArray processList = result == null ? null : result.getJSONArray("process_list");
|
||||
if (processList == null) {
|
||||
return map;
|
||||
}
|
||||
for (int i = 0; i < processList.size(); i++) {
|
||||
JSONObject item = processList.getJSONObject(i);
|
||||
String code = item.getString("process_code");
|
||||
String name = oConvertUtils.getString(item.getString("name"), "").trim();
|
||||
if (oConvertUtils.isNotEmpty(code) && oConvertUtils.isNotEmpty(name)) {
|
||||
map.put(code, name);
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
/** 将本地模板元数据(名称/描述/表单)推送到钉钉 */
|
||||
private Result<String> pushTemplateMetaToDingtalk(MesXslDingProcessTpl tpl) {
|
||||
if (tpl == null) {
|
||||
return Result.error("模板不存在");
|
||||
}
|
||||
if (oConvertUtils.isEmpty(tpl.getProcessCode())) {
|
||||
return Result.error("该记录尚无 processCode");
|
||||
}
|
||||
String accessToken = dingtalkService.getAccessToken();
|
||||
if (oConvertUtils.isEmpty(accessToken)) {
|
||||
return Result.error("AccessToken 获取失败");
|
||||
}
|
||||
List<DingFormComponent> components = buildFormComponentList(tpl.getFormFields());
|
||||
if (components.isEmpty()) {
|
||||
return Result.error("请先在表单设计中配置至少一个字段后再同步到钉钉");
|
||||
}
|
||||
DingFormUpdateRequest req = new DingFormUpdateRequest()
|
||||
.setProcessCode(tpl.getProcessCode())
|
||||
.setName(tpl.getTplName())
|
||||
.setDescription(oConvertUtils.isNotEmpty(tpl.getRemark()) ? tpl.getRemark() : "")
|
||||
.setFormComponents(components);
|
||||
try {
|
||||
String reqJson = JSON.toJSONString(req);
|
||||
log.info("【钉钉更新模板】processCode={} name={} 请求体: {}", tpl.getProcessCode(), tpl.getTplName(), reqJson);
|
||||
String respBody = callDingApi("POST", "https://api.dingtalk.com/v1.0/workflow/forms", accessToken, reqJson);
|
||||
JSONObject resp = JSONObject.parseObject(respBody);
|
||||
log.info("【钉钉更新模板】响应: {}", respBody);
|
||||
if (resp.containsKey("code")) {
|
||||
return Result.error("钉钉返回错误: " + resp.getString("message") + " (code=" + resp.getString("code") + ")");
|
||||
}
|
||||
return Result.OK("同步成功");
|
||||
} catch (Exception e) {
|
||||
log.error("推送钉钉模板元数据异常 processCode={}", tpl.getProcessCode(), e);
|
||||
return Result.error("请求异常: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
//update-end---author:GHT ---date:20260610 for:【MESToDing审批配置】模板名称 MES↔钉钉 双向同步-----------
|
||||
|
||||
/** 统一 HTTP 调用钉钉 v1.0 接口 */
|
||||
private String callDingApi(String method, String url, String accessToken, String jsonBody) throws Exception {
|
||||
HttpClient httpClient = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(10)).build();
|
||||
@@ -1110,6 +1325,9 @@ public class MesXslDingProcessTplController extends JeecgController<MesXslDingPr
|
||||
if (tpl == null) {
|
||||
return Result.error("未找到对应模板配置");
|
||||
}
|
||||
if (!"1".equals(tpl.getStatus())) {
|
||||
return Result.error("该审批模板已停用,无法发起钉钉审批");
|
||||
}
|
||||
if (oConvertUtils.isEmpty(tpl.getProcessCode())) {
|
||||
return Result.error("该模板尚无 processCode,请先在钉钉管理后台创建审批模板");
|
||||
}
|
||||
@@ -1240,6 +1458,19 @@ public class MesXslDingProcessTplController extends JeecgController<MesXslDingPr
|
||||
}
|
||||
//update-end---author:GHT ---date:2026-06-04 for:【MESToDing审批配置】抄送人节点映射钉钉ccList-----------
|
||||
|
||||
//update-begin---author:GHT ---date:20260605 for:【XSLMES-20260605-K8R4】发起前调用processForecast构建节点序号映射-----------
|
||||
String nodeActivityMapJson = null;
|
||||
try {
|
||||
JSONArray forecastRules = callProcessForecast(accessToken, reqBody);
|
||||
nodeActivityMapJson = buildNodeActivityMapJson(forecastRules);
|
||||
if (oConvertUtils.isNotEmpty(nodeActivityMapJson)) {
|
||||
log.info("【钉钉发起审批】nodeActivityMap构建成功 entries={}", JSONArray.parseArray(nodeActivityMapJson).size());
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.warn("【钉钉发起审批】processForecast失败,将使用索引映射降级: {}", e.getMessage());
|
||||
}
|
||||
//update-end---author:GHT ---date:20260605 for:【XSLMES-20260605-K8R4】发起前调用processForecast构建节点序号映射-----------
|
||||
|
||||
try {
|
||||
String reqJson = reqBody.toJSONString();
|
||||
log.info("【钉钉发起审批】processCode={} flowId={}\n请求体(formComponentValues共{}项, approvers共{}步, ccList共{}人):\n{}",
|
||||
@@ -1256,12 +1487,18 @@ public class MesXslDingProcessTplController extends JeecgController<MesXslDingPr
|
||||
|
||||
String dingInstanceId = resp.getString("instanceId");
|
||||
//update-begin---author:GHT ---date:20260604 for:【QH-MES审批台账】钉钉发起成功后写入审批台账-----
|
||||
//update-begin---author:GHT ---date:20260605 for:【XSLMES-20260605-K8R4】台账写入nodeActivityMap节点映射-----------
|
||||
if (oConvertUtils.isNotEmpty(bizTable) && oConvertUtils.isNotEmpty(bizDataId)) {
|
||||
approvalGateService.createRunningRecord(
|
||||
approvalGateService.buildDingDraft(bizTable, approvalFlow.getBizTableName(), bizCode, bizDataId,
|
||||
bizTitle, flowId, approvalFlow.getFlowName(), tpl.getId(), tpl.getTplName(),
|
||||
dingInstanceId, loginUser, tenantId));
|
||||
MesXslApprovalRecord dingRecord = approvalGateService.buildDingDraft(
|
||||
bizTable, approvalFlow.getBizTableName(), bizCode, bizDataId,
|
||||
bizTitle, flowId, approvalFlow.getFlowName(), tpl.getId(), tpl.getTplName(),
|
||||
dingInstanceId, loginUser, tenantId);
|
||||
if (oConvertUtils.isNotEmpty(nodeActivityMapJson)) {
|
||||
dingRecord.setNodeActivityMap(nodeActivityMapJson);
|
||||
}
|
||||
approvalGateService.createRunningRecord(dingRecord);
|
||||
}
|
||||
//update-end---author:GHT ---date:20260605 for:【XSLMES-20260605-K8R4】台账写入nodeActivityMap节点映射-----------
|
||||
//update-end---author:GHT ---date:20260604 for:【QH-MES审批台账】钉钉发起成功后写入审批台账-----
|
||||
|
||||
Map<String, Object> result = new LinkedHashMap<>();
|
||||
@@ -1567,43 +1804,33 @@ public class MesXslDingProcessTplController extends JeecgController<MesXslDingPr
|
||||
}
|
||||
//update-end---author:GHT ---date:2026-06-04 for:【MESToDing审批配置】抄送人节点映射钉钉ccList-----------
|
||||
|
||||
//update-begin---author:GHT ---date:2026-06-08 for:【XSLMES-20260608】resolveDtUserIdWithFallback简化:手机号统一由resolveDtUserId三级降级处理-----------
|
||||
/**
|
||||
* 按用户名解析钉钉 userId,带本次调用级缓存。
|
||||
* 解析顺序:
|
||||
* ① sys_third_account(已完成钉钉账号绑定)
|
||||
* ② sys_user.phone → JdtUserAPI.getUseridByMobile(手机号已在企业钉钉注册)
|
||||
* 实际解析由 {@link #resolveDtUserId} 完成(三级降级:ding_user_id → sys_third_account → phone→API)。
|
||||
*/
|
||||
private String resolveDtUserIdWithFallback(String username, String accessToken, int tenantId,
|
||||
Map<String, String> cache) {
|
||||
if (cache.containsKey(username)) {
|
||||
return cache.get(username);
|
||||
}
|
||||
// ① sys_third_account
|
||||
String dtId = resolveDtUserId(username, null, accessToken, tenantId);
|
||||
if (oConvertUtils.isNotEmpty(dtId)) {
|
||||
cache.put(username, dtId);
|
||||
return dtId;
|
||||
}
|
||||
// ② 从 sys_user 取手机号再查
|
||||
// 预先从 sys_user 取手机号,一并传给 resolveDtUserId 作为最终降级依据
|
||||
String phone = null;
|
||||
try {
|
||||
List<String> phones = jdbcTemplate.queryForList(
|
||||
"SELECT phone FROM sys_user WHERE username = ? AND (del_flag = 0 OR del_flag IS NULL) LIMIT 1",
|
||||
"SELECT phone FROM sys_user WHERE username=? AND (del_flag=0 OR del_flag IS NULL) LIMIT 1",
|
||||
String.class, username);
|
||||
if (!phones.isEmpty() && oConvertUtils.isNotEmpty(phones.get(0))) {
|
||||
String phone = phones.get(0).trim();
|
||||
Response<String> resp = JdtUserAPI.getUseridByMobile(phone, accessToken);
|
||||
if (resp.isSuccess() && oConvertUtils.isNotEmpty(resp.getResult())) {
|
||||
dtId = resp.getResult();
|
||||
cache.put(username, dtId);
|
||||
return dtId;
|
||||
}
|
||||
phone = phones.get(0).trim();
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.warn("查询用户 {} 手机号失败: {}", username, e.getMessage());
|
||||
log.warn("[resolveDtUserIdWithFallback] 查询手机号失败 username={}: {}", username, e.getMessage());
|
||||
}
|
||||
cache.put(username, null);
|
||||
return null;
|
||||
String dtId = resolveDtUserId(username, phone, accessToken, tenantId);
|
||||
cache.put(username, dtId);
|
||||
return dtId;
|
||||
}
|
||||
//update-end---author:GHT ---date:2026-06-08 for:【XSLMES-20260608】resolveDtUserIdWithFallback简化:手机号统一由resolveDtUserId三级降级处理-----------
|
||||
//update-end---author:GHT ---date:2026-06-04 for:【MESToDing审批配置】审批流解析审批人-兜底用sys_user手机号查钉钉-----------
|
||||
|
||||
/** 深度遍历节点树,收集所有 type=approver 的节点(包含条件分支路径上的节点) */
|
||||
@@ -1677,25 +1904,47 @@ public class MesXslDingProcessTplController extends JeecgController<MesXslDingPr
|
||||
}
|
||||
//update-end---author:GHT ---date:2026-06-03 for:【MESToDing审批配置】通过userId查用户所属部门ID-----------
|
||||
|
||||
//update-begin---author:GHT ---date:2026-06-08 for:【XSLMES-20260608】resolveDtUserId优先查本地ding_user_id,减少DingTalk API调用-----------
|
||||
/**
|
||||
* 解析用户的钉钉 userId:优先从 sys_third_account 查已绑定的,其次用手机号降级查询。
|
||||
* 解析用户的钉钉 userId,三级降级:
|
||||
* ① sys_user.ding_user_id(本地字段,无需 API,最快)
|
||||
* ② sys_third_account.third_user_id(第三方账号绑定表)
|
||||
* ③ phone → JdtUserAPI.getUseridByMobile(最终降级,发起网络调用)
|
||||
*/
|
||||
private String resolveDtUserId(String username, String phone, String accessToken, int tenantId) {
|
||||
if (oConvertUtils.isNotEmpty(username)) {
|
||||
// ① sys_user.ding_user_id
|
||||
try {
|
||||
List<String> dingIds = jdbcTemplate.queryForList(
|
||||
"SELECT ding_user_id FROM sys_user WHERE username=? AND (del_flag=0 OR del_flag IS NULL)"
|
||||
+ " AND ding_user_id IS NOT NULL AND ding_user_id!='' LIMIT 1",
|
||||
String.class, username);
|
||||
if (!dingIds.isEmpty() && oConvertUtils.isNotEmpty(dingIds.get(0))) {
|
||||
log.info("[resolveDtUserId] ding_user_id命中 username={} dtId={}", username, dingIds.get(0));
|
||||
return dingIds.get(0);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.warn("[resolveDtUserId] 查询ding_user_id异常 username={}: {}", username, e.getMessage());
|
||||
}
|
||||
// ② sys_third_account
|
||||
List<SysThirdAccount> accounts = sysThirdAccountService.listThirdUserIdByUsername(
|
||||
new String[]{username}, "dingtalk", tenantId);
|
||||
if (accounts != null && !accounts.isEmpty() && oConvertUtils.isNotEmpty(accounts.get(0).getThirdUserId())) {
|
||||
log.info("[resolveDtUserId] sys_third_account命中 username={} dtId={}", username, accounts.get(0).getThirdUserId());
|
||||
return accounts.get(0).getThirdUserId();
|
||||
}
|
||||
}
|
||||
// ③ phone → DingTalk API(最终降级)
|
||||
if (oConvertUtils.isNotEmpty(phone)) {
|
||||
Response<String> resp = JdtUserAPI.getUseridByMobile(phone, accessToken);
|
||||
if (resp.isSuccess() && oConvertUtils.isNotEmpty(resp.getResult())) {
|
||||
log.info("[resolveDtUserId] 手机号降级成功 phone={} dtId={}", phone, resp.getResult());
|
||||
return resp.getResult();
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
//update-end---author:GHT ---date:2026-06-08 for:【XSLMES-20260608】resolveDtUserId优先查本地ding_user_id,减少DingTalk API调用-----------
|
||||
|
||||
//update-begin---author:GHT ---date:20260604 for:【QH-MES审批台账】钉钉发起参数解析辅助-----
|
||||
private String str(Object value) {
|
||||
@@ -1709,5 +1958,68 @@ public class MesXslDingProcessTplController extends JeecgController<MesXslDingPr
|
||||
return oConvertUtils.isNotEmpty(second) ? second.trim() : "";
|
||||
}
|
||||
//update-end---author:GHT ---date:20260604 for:【QH-MES审批台账】钉钉发起参数解析辅助-----
|
||||
|
||||
//update-begin---author:GHT ---date:20260605 for:【XSLMES-20260605-K8R4】processForecast节点映射辅助方法-----------
|
||||
/**
|
||||
* 调用钉钉 processForecast 接口预测审批流节点信息。
|
||||
* 使用与 createProcessInstance 相同的请求体,不会实际发起审批。
|
||||
*
|
||||
* @return workflowActivityRules 数组(每项含 approvalMethod/activityActioners),失败返回 null
|
||||
*/
|
||||
private JSONArray callProcessForecast(String accessToken, JSONObject processReqBody) {
|
||||
try {
|
||||
String respBody = callDingApi("POST", "https://api.dingtalk.com/v1.0/workflow/process/forecast",
|
||||
accessToken, processReqBody.toJSONString());
|
||||
JSONObject resp = JSONObject.parseObject(respBody);
|
||||
if (resp.containsKey("code")) {
|
||||
log.warn("【钉钉processForecast】返回错误 code={} msg={}", resp.getString("code"), resp.getString("message"));
|
||||
return null;
|
||||
}
|
||||
JSONObject result = resp.getJSONObject("result");
|
||||
return result != null ? result.getJSONArray("workflowActivityRules") : null;
|
||||
} catch (Exception e) {
|
||||
log.warn("【钉钉processForecast】调用异常: {}", e.getMessage());
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 将 processForecast 返回的 workflowActivityRules 转换为节点序号映射 JSON 数组。
|
||||
* 每项含:approvalMethod, totalActioners, completionAt(累计任务数边界)。
|
||||
* <p>
|
||||
* completionAt 计算规则:
|
||||
* - AND / ONE_BY_ONE:节点需要所有审批人完成,effectiveOps = totalActioners
|
||||
* - OR / NONE 或单人:首位通过即完成,effectiveOps = 1
|
||||
* <p>
|
||||
* 在 bpms_task_change 回调中,当 taskOps.size() == completionAt 时表示该节点刚完成。
|
||||
*/
|
||||
private String buildNodeActivityMapJson(JSONArray forecastRules) {
|
||||
if (forecastRules == null || forecastRules.isEmpty()) return null;
|
||||
try {
|
||||
JSONArray mapArr = new JSONArray();
|
||||
int cumulative = 0;
|
||||
for (int i = 0; i < forecastRules.size(); i++) {
|
||||
JSONObject rule = forecastRules.getJSONObject(i);
|
||||
String approvalMethod = oConvertUtils.isNotEmpty(rule.getString("approvalMethod"))
|
||||
? rule.getString("approvalMethod") : "NONE";
|
||||
JSONArray actioners = rule.getJSONArray("activityActioners");
|
||||
int totalActioners = (actioners != null && !actioners.isEmpty()) ? actioners.size() : 1;
|
||||
boolean waitForAll = "AND".equalsIgnoreCase(approvalMethod)
|
||||
|| "ONE_BY_ONE".equalsIgnoreCase(approvalMethod);
|
||||
int effectiveOps = (waitForAll && totalActioners > 1) ? totalActioners : 1;
|
||||
cumulative += effectiveOps;
|
||||
JSONObject entry = new JSONObject();
|
||||
entry.put("approvalMethod", approvalMethod);
|
||||
entry.put("totalActioners", totalActioners);
|
||||
entry.put("completionAt", cumulative);
|
||||
mapArr.add(entry);
|
||||
}
|
||||
return mapArr.toJSONString();
|
||||
} catch (Exception e) {
|
||||
log.warn("【钉钉发起审批】构建nodeActivityMap失败: {}", e.getMessage());
|
||||
return null;
|
||||
}
|
||||
}
|
||||
//update-end---author:GHT ---date:20260605 for:【XSLMES-20260605-K8R4】processForecast节点映射辅助方法-----------
|
||||
//update-end---author:GHT ---date:2026-06-03 for:【MESToDing审批配置】手动填表发起钉钉审批-----------
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ import org.jeecg.modules.print.vo.PrintBizFieldItemVO;
|
||||
import org.jeecg.modules.print.vo.PrintBizTypeVO;
|
||||
import org.jeecg.modules.xslmes.dingtalk.entity.MesXslDingTplBind;
|
||||
import org.jeecg.modules.xslmes.dingtalk.entity.MesXslDingProcessTpl;
|
||||
import org.jeecg.modules.xslmes.dingtalk.service.DingTplBindFieldValueResolver;
|
||||
import org.jeecg.modules.xslmes.dingtalk.service.IMesXslDingTplBindService;
|
||||
import org.jeecg.modules.xslmes.dingtalk.service.IMesXslDingProcessTplService;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
@@ -46,6 +47,8 @@ public class MesXslDingTplBindController {
|
||||
@Autowired(required = false)
|
||||
private IPrintBizEntityFieldCatalogProvider fieldCatalogProvider;
|
||||
|
||||
@Autowired private DingTplBindFieldValueResolver fieldValueResolver;
|
||||
|
||||
// ═══════════════════════ 菜单树 ═══════════════════════
|
||||
|
||||
/**
|
||||
@@ -129,19 +132,7 @@ public class MesXslDingTplBindController {
|
||||
if (StringUtils.isBlank(bizCode)) {
|
||||
return Result.error("bizCode 不能为空");
|
||||
}
|
||||
String code = bizCode.trim();
|
||||
if (fieldCatalogProvider != null && fieldCatalogProvider.hasCatalogForBiz(code)) {
|
||||
return Result.OK(fieldCatalogProvider.listMainFields(code));
|
||||
}
|
||||
PrintBizTypeVO vo = printBizPermEntityService.resolveBizTypeVo(code);
|
||||
if (vo == null || StringUtils.isBlank(vo.getDescription())) {
|
||||
return Result.OK(Collections.emptyList());
|
||||
}
|
||||
Class<?> cls = PrintBizEntityFieldIntrospector.tryLoadClass(vo.getDescription().trim());
|
||||
if (cls == null) {
|
||||
return Result.OK(Collections.emptyList());
|
||||
}
|
||||
return Result.OK(PrintBizEntityFieldIntrospector.listFields(cls));
|
||||
return Result.OK(listMainFieldsEnriched(bizCode.trim()));
|
||||
}
|
||||
|
||||
@Operation(summary = "主实体上的明细槽位(供 TableField 绑定明细集合)")
|
||||
@@ -177,20 +168,53 @@ public class MesXslDingTplBindController {
|
||||
return Result.error("bizCode 与 detailProperty 不能为空");
|
||||
}
|
||||
String code = bizCode.trim();
|
||||
String prop = detailProperty.trim();
|
||||
String kind = slotKind.trim();
|
||||
List<PrintBizFieldItemVO> fields;
|
||||
if (fieldCatalogProvider != null && fieldCatalogProvider.hasCatalogForBiz(code)) {
|
||||
return Result.OK(fieldCatalogProvider.listPrefixedDetailFields(code, detailProperty.trim(), slotKind.trim()));
|
||||
fields = fieldCatalogProvider.listPrefixedDetailFields(code, prop, kind);
|
||||
} else {
|
||||
Class<?> cls = resolveEntityClass(code);
|
||||
if (cls == null) {
|
||||
return Result.OK(Collections.emptyList());
|
||||
}
|
||||
fields = PrintBizDetailPropertyScanner.listPrefixedDetailFields(cls, prop, kind);
|
||||
}
|
||||
PrintBizTypeVO vo = printBizPermEntityService.resolveBizTypeVo(code);
|
||||
if (vo == null || StringUtils.isBlank(vo.getDescription())) {
|
||||
return Result.OK(Collections.emptyList());
|
||||
Class<?> itemCls = resolveDetailItemClass(code, prop, kind);
|
||||
if (itemCls != null && fields != null && !fields.isEmpty()) {
|
||||
PrintBizEntityFieldIntrospector.enrichDictMeta(fields, itemCls, prop);
|
||||
}
|
||||
Class<?> cls = PrintBizEntityFieldIntrospector.tryLoadClass(vo.getDescription().trim());
|
||||
if (cls == null) {
|
||||
return Result.OK(Collections.emptyList());
|
||||
}
|
||||
return Result.OK(PrintBizDetailPropertyScanner.listPrefixedDetailFields(cls, detailProperty.trim(), slotKind.trim()));
|
||||
return Result.OK(fields != null ? fields : Collections.emptyList());
|
||||
}
|
||||
|
||||
//update-begin---author:GHT ---date:2026-06-10 for:【钉钉审批模板绑定】批量解析字段取值(原值/显示文本)-----------
|
||||
@Operation(summary = "批量解析绑定字段取值(字典/表字典显示文本)")
|
||||
@PostMapping("/resolveFieldValues")
|
||||
@RequiresPermissions("xslmes:mesXslDingTplBind:list")
|
||||
public Result<Map<String, Object>> resolveFieldValues(@RequestBody ResolveFieldValuesRequest req) {
|
||||
if (req == null || StringUtils.isBlank(req.getBizCode())) {
|
||||
return Result.error("bizCode 不能为空");
|
||||
}
|
||||
if (req.getRowData() == null || req.getItems() == null || req.getItems().isEmpty()) {
|
||||
return Result.OK(Collections.emptyMap());
|
||||
}
|
||||
String code = req.getBizCode().trim();
|
||||
Map<String, PrintBizFieldItemVO> metaMap = buildFieldMetaMap(code);
|
||||
Map<String, Object> out = new LinkedHashMap<>();
|
||||
for (ResolveFieldItem item : req.getItems()) {
|
||||
if (item == null || StringUtils.isBlank(item.getMapKey()) || StringUtils.isBlank(item.getBizField())) {
|
||||
continue;
|
||||
}
|
||||
PrintBizFieldItemVO meta = metaMap.get(item.getBizField().trim());
|
||||
Object val =
|
||||
fieldValueResolver.resolveValue(
|
||||
req.getRowData(), item.getBizField().trim(), item.getValueMode(), meta);
|
||||
out.put(item.getMapKey(), val);
|
||||
}
|
||||
return Result.OK(out);
|
||||
}
|
||||
//update-end---author:GHT ---date:2026-06-10 for:【钉钉审批模板绑定】批量解析字段取值(原值/显示文本)-----------
|
||||
|
||||
// ═══════════════════════ 按路由检测绑定(全局悬浮按钮使用) ═══════════════════════
|
||||
|
||||
/**
|
||||
@@ -216,6 +240,15 @@ public class MesXslDingTplBindController {
|
||||
return Result.OK(null);
|
||||
}
|
||||
MesXslDingTplBind bind = bindService.getByBizCode(ids.get(0));
|
||||
if (bind == null || StringUtils.isBlank(bind.getTemplateId())) {
|
||||
return Result.OK(null);
|
||||
}
|
||||
//update-begin---author:GHT ---date:20260610 for:【钉钉审批模板】模板停用时业务页不返回绑定,隐藏钉钉审批按钮-----------
|
||||
MesXslDingProcessTpl tpl = tplService.getById(bind.getTemplateId());
|
||||
if (tpl == null || !"1".equals(tpl.getStatus())) {
|
||||
return Result.OK(null);
|
||||
}
|
||||
//update-end---author:GHT ---date:20260610 for:【钉钉审批模板】模板停用时业务页不返回绑定,隐藏钉钉审批按钮-----------
|
||||
return Result.OK(bind);
|
||||
}
|
||||
|
||||
@@ -247,6 +280,9 @@ public class MesXslDingTplBindController {
|
||||
if (tpl == null) {
|
||||
return Result.error("选择的钉钉审批模板不存在");
|
||||
}
|
||||
if (!"1".equals(tpl.getStatus())) {
|
||||
return Result.error("所选钉钉审批模板已停用,请先启用或更换其他模板");
|
||||
}
|
||||
MesXslDingTplBind existing = bindService.getByBizCode(req.getBizCode().trim());
|
||||
if (existing != null) {
|
||||
// 更新已有绑定
|
||||
@@ -297,6 +333,82 @@ public class MesXslDingTplBindController {
|
||||
return "[]";
|
||||
}
|
||||
|
||||
private Class<?> resolveEntityClass(String bizCode) {
|
||||
PrintBizTypeVO vo = printBizPermEntityService.resolveBizTypeVo(bizCode);
|
||||
if (vo == null || StringUtils.isBlank(vo.getDescription())) {
|
||||
return null;
|
||||
}
|
||||
return PrintBizEntityFieldIntrospector.tryLoadClass(vo.getDescription().trim());
|
||||
}
|
||||
|
||||
private List<PrintBizFieldItemVO> listMainFieldsEnriched(String bizCode) {
|
||||
Class<?> cls = resolveEntityClass(bizCode);
|
||||
List<PrintBizFieldItemVO> fields;
|
||||
if (fieldCatalogProvider != null && fieldCatalogProvider.hasCatalogForBiz(bizCode)) {
|
||||
fields = fieldCatalogProvider.listMainFields(bizCode);
|
||||
} else if (cls != null) {
|
||||
fields = PrintBizEntityFieldIntrospector.listFields(cls);
|
||||
} else {
|
||||
return Collections.emptyList();
|
||||
}
|
||||
if (cls != null && fields != null && !fields.isEmpty()) {
|
||||
PrintBizEntityFieldIntrospector.enrichDictMeta(fields, cls, null);
|
||||
}
|
||||
return fields != null ? fields : Collections.emptyList();
|
||||
}
|
||||
|
||||
private Class<?> resolveDetailItemClass(String bizCode, String detailProperty, String slotKind) {
|
||||
Class<?> mainCls = resolveEntityClass(bizCode);
|
||||
if (mainCls == null) {
|
||||
return null;
|
||||
}
|
||||
return PrintBizDetailPropertyScanner.resolveItemClassForSlot(mainCls, detailProperty, slotKind);
|
||||
}
|
||||
|
||||
private Map<String, PrintBizFieldItemVO> buildFieldMetaMap(String bizCode) {
|
||||
Map<String, PrintBizFieldItemVO> map = new LinkedHashMap<>();
|
||||
for (PrintBizFieldItemVO f : listMainFieldsEnriched(bizCode)) {
|
||||
if (f != null && StringUtils.isNotBlank(f.getFieldKey())) {
|
||||
map.put(f.getFieldKey(), f);
|
||||
}
|
||||
}
|
||||
Class<?> mainCls = resolveEntityClass(bizCode);
|
||||
if (mainCls != null && fieldCatalogProvider != null && fieldCatalogProvider.hasCatalogForBiz(bizCode)) {
|
||||
for (PrintBizDetailSlotVO slot : fieldCatalogProvider.listDetailSlots(bizCode)) {
|
||||
mergeDetailMeta(map, bizCode, slot.getPropertyName(), slot.getSlotKind());
|
||||
}
|
||||
} else if (mainCls != null) {
|
||||
for (PrintBizDetailSlotVO slot : PrintBizDetailPropertyScanner.listSlots(mainCls)) {
|
||||
mergeDetailMeta(map, bizCode, slot.getPropertyName(), slot.getSlotKind());
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
private void mergeDetailMeta(Map<String, PrintBizFieldItemVO> map, String bizCode, String prop, String kind) {
|
||||
List<PrintBizFieldItemVO> detailFields;
|
||||
if (fieldCatalogProvider != null && fieldCatalogProvider.hasCatalogForBiz(bizCode)) {
|
||||
detailFields = fieldCatalogProvider.listPrefixedDetailFields(bizCode, prop, kind);
|
||||
} else {
|
||||
Class<?> mainCls = resolveEntityClass(bizCode);
|
||||
detailFields =
|
||||
mainCls == null
|
||||
? Collections.emptyList()
|
||||
: PrintBizDetailPropertyScanner.listPrefixedDetailFields(mainCls, prop, kind);
|
||||
}
|
||||
Class<?> itemCls = resolveDetailItemClass(bizCode, prop, kind);
|
||||
if (itemCls != null && detailFields != null && !detailFields.isEmpty()) {
|
||||
PrintBizEntityFieldIntrospector.enrichDictMeta(detailFields, itemCls, prop);
|
||||
}
|
||||
if (detailFields != null) {
|
||||
for (PrintBizFieldItemVO f : detailFields) {
|
||||
if (f != null && StringUtils.isNotBlank(f.getFieldKey())) {
|
||||
map.put(f.getFieldKey(), f);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ═══════════════════════ 内部 VO ═══════════════════════
|
||||
|
||||
@Data
|
||||
@@ -316,4 +428,20 @@ public class MesXslDingTplBindController {
|
||||
private String templateId;
|
||||
private String fieldMappingJson;
|
||||
}
|
||||
|
||||
@Data
|
||||
public static class ResolveFieldValuesRequest {
|
||||
private String bizCode;
|
||||
private Map<String, Object> rowData;
|
||||
private List<ResolveFieldItem> items;
|
||||
}
|
||||
|
||||
@Data
|
||||
public static class ResolveFieldItem {
|
||||
/** 前端映射键,通常为 componentId */
|
||||
private String mapKey;
|
||||
private String bizField;
|
||||
/** raw 或 text */
|
||||
private String valueMode;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,272 @@
|
||||
package org.jeecg.modules.xslmes.dingtalk.service;
|
||||
|
||||
import com.alibaba.fastjson2.JSONArray;
|
||||
import com.alibaba.fastjson2.JSONObject;
|
||||
import com.jeecg.dingtalk.api.core.response.Response;
|
||||
import com.jeecg.dingtalk.api.user.JdtUserAPI;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.jeecg.common.util.oConvertUtils;
|
||||
import org.jeecg.modules.system.entity.SysThirdAccount;
|
||||
import org.jeecg.modules.system.service.ISysThirdAccountService;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.HashMap;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 按 MES 审批流配置构建钉钉发起/预测审批请求体(含 approvers)。
|
||||
* 与 {@link org.jeecg.modules.xslmes.dingtalk.controller.MesXslDingProcessTplController} 发起逻辑保持一致。
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
public class DingApprovalLaunchParamBuilder {
|
||||
|
||||
@Autowired
|
||||
private JdbcTemplate jdbcTemplate;
|
||||
|
||||
@Autowired
|
||||
private ISysThirdAccountService sysThirdAccountService;
|
||||
|
||||
//update-begin---author:GHT ---date:20260608 for:【审批注册中心】按MES发起参数构建processForecast请求体-----------
|
||||
/**
|
||||
* 构建与 MES 发起钉钉审批时一致的 processForecast 请求体。
|
||||
*/
|
||||
public JSONObject buildForecastRequest(String processCode, String flowConfig, JSONObject instance,
|
||||
String accessToken, Integer tenantId) {
|
||||
JSONObject req = new JSONObject();
|
||||
req.put("processCode", processCode);
|
||||
String originatorUserId = instance.getString("originatorUserId");
|
||||
if (oConvertUtils.isNotEmpty(originatorUserId)) {
|
||||
req.put("originatorUserId", originatorUserId);
|
||||
req.put("userId", originatorUserId);
|
||||
}
|
||||
String deptIdStr = instance.getString("originatorDeptId");
|
||||
if (oConvertUtils.isNotEmpty(deptIdStr)) {
|
||||
try {
|
||||
req.put("deptId", Long.parseLong(deptIdStr));
|
||||
} catch (NumberFormatException e) {
|
||||
req.put("deptId", deptIdStr);
|
||||
}
|
||||
}
|
||||
JSONArray formValues = instance.getJSONArray("formComponentValues");
|
||||
if (formValues != null && !formValues.isEmpty()) {
|
||||
req.put("formComponentValues", formValues);
|
||||
}
|
||||
if (oConvertUtils.isNotEmpty(flowConfig) && oConvertUtils.isNotEmpty(originatorUserId)) {
|
||||
int tid = tenantId == null ? 0 : tenantId;
|
||||
JSONArray approvers = buildApproversFromFlowConfig(flowConfig, originatorUserId, accessToken, tid);
|
||||
if (!approvers.isEmpty()) {
|
||||
req.put("approvers", approvers);
|
||||
}
|
||||
}
|
||||
return req;
|
||||
}
|
||||
|
||||
/**
|
||||
* 按 MES 审批流 DFS 顺序提取审批节点名称(与 approvers 数组顺序对齐)。
|
||||
*/
|
||||
public List<String> listMesApproverNodeNames(String flowConfig) {
|
||||
List<String> names = new ArrayList<>();
|
||||
if (oConvertUtils.isEmpty(flowConfig)) {
|
||||
return names;
|
||||
}
|
||||
try {
|
||||
JSONObject root = JSONObject.parseObject(flowConfig);
|
||||
List<JSONObject> approverNodes = new ArrayList<>();
|
||||
collectApproverNodes(root, approverNodes);
|
||||
LinkedHashSet<String> visited = new LinkedHashSet<>();
|
||||
for (JSONObject node : approverNodes) {
|
||||
String nid = node.getString("id");
|
||||
if (nid != null && !visited.add(nid)) {
|
||||
continue;
|
||||
}
|
||||
JSONObject props = node.getJSONObject("props");
|
||||
if (props == null) {
|
||||
continue;
|
||||
}
|
||||
String approverType = props.getString("approverType");
|
||||
if ("leader".equals(approverType) || "field".equals(approverType)) {
|
||||
continue;
|
||||
}
|
||||
String nodeName = node.getString("name");
|
||||
names.add(oConvertUtils.isNotEmpty(nodeName) ? nodeName : "审批节点");
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.warn("解析MES审批流节点名称失败: {}", e.getMessage());
|
||||
}
|
||||
return names;
|
||||
}
|
||||
|
||||
public JSONArray buildApproversFromFlowConfig(String flowConfig, String originatorDtUserId,
|
||||
String accessToken, int tenantId) {
|
||||
JSONArray approvers = new JSONArray();
|
||||
Map<String, String> dtIdCache = new HashMap<>();
|
||||
try {
|
||||
JSONObject root = JSONObject.parseObject(flowConfig);
|
||||
List<JSONObject> approverNodes = new ArrayList<>();
|
||||
collectApproverNodes(root, approverNodes);
|
||||
LinkedHashSet<String> visitedNodeIds = new LinkedHashSet<>();
|
||||
List<JSONObject> dedupedNodes = new ArrayList<>();
|
||||
for (JSONObject n : approverNodes) {
|
||||
String nid = n.getString("id");
|
||||
if (nid == null || visitedNodeIds.add(nid)) {
|
||||
dedupedNodes.add(n);
|
||||
}
|
||||
}
|
||||
for (JSONObject node : dedupedNodes) {
|
||||
JSONObject props = node.getJSONObject("props");
|
||||
if (props == null) {
|
||||
continue;
|
||||
}
|
||||
String approverType = props.getString("approverType");
|
||||
String multiMode = props.getString("multiMode");
|
||||
String actionType;
|
||||
boolean isSingleMode = "none".equals(multiMode);
|
||||
if ("or".equals(multiMode)) {
|
||||
actionType = "OR";
|
||||
} else if (isSingleMode) {
|
||||
actionType = "NONE";
|
||||
} else {
|
||||
actionType = "AND";
|
||||
}
|
||||
List<String> dtUserIds = new ArrayList<>();
|
||||
if ("user".equals(approverType)) {
|
||||
String userText = props.getString("userText");
|
||||
if (oConvertUtils.isNotEmpty(userText)) {
|
||||
for (String username : userText.split("[,,\\s]+")) {
|
||||
username = username.trim();
|
||||
if (oConvertUtils.isEmpty(username)) {
|
||||
continue;
|
||||
}
|
||||
String dtId = resolveDtUserIdWithFallback(username, accessToken, tenantId, dtIdCache);
|
||||
if (oConvertUtils.isNotEmpty(dtId)) {
|
||||
dtUserIds.add(dtId);
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if ("self".equals(approverType)) {
|
||||
dtUserIds.add(originatorDtUserId);
|
||||
} else if ("role".equals(approverType)) {
|
||||
JSONArray roleList = props.getJSONArray("roleList");
|
||||
if (roleList != null && !roleList.isEmpty()) {
|
||||
List<String> roleIds = new ArrayList<>();
|
||||
for (int ri = 0; ri < roleList.size(); ri++) {
|
||||
String rid = roleList.getString(ri);
|
||||
if (oConvertUtils.isNotEmpty(rid)) {
|
||||
roleIds.add(rid);
|
||||
}
|
||||
}
|
||||
if (!roleIds.isEmpty()) {
|
||||
String inClause = String.join(",", Collections.nCopies(roleIds.size(), "?"));
|
||||
List<String> usernames = jdbcTemplate.queryForList(
|
||||
"SELECT DISTINCT u.username FROM sys_user u"
|
||||
+ " INNER JOIN sys_user_role sur ON sur.user_id = u.id"
|
||||
+ " WHERE sur.role_id IN (" + inClause + ")"
|
||||
+ " AND (u.del_flag = 0 OR u.del_flag IS NULL)",
|
||||
String.class, roleIds.toArray());
|
||||
for (String username : usernames) {
|
||||
String dtId = resolveDtUserIdWithFallback(username, accessToken, tenantId, dtIdCache);
|
||||
if (oConvertUtils.isNotEmpty(dtId)) {
|
||||
dtUserIds.add(dtId);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
continue;
|
||||
}
|
||||
if (!dtUserIds.isEmpty()) {
|
||||
List<String> unique = new ArrayList<>(new LinkedHashSet<>(dtUserIds));
|
||||
if (isSingleMode && unique.size() > 1) {
|
||||
unique = unique.subList(0, 1);
|
||||
}
|
||||
JSONObject step = new JSONObject();
|
||||
step.put("actionType", actionType);
|
||||
step.put("userIds", unique);
|
||||
approvers.add(step);
|
||||
}
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.error("解析MES审批流approvers失败", e);
|
||||
}
|
||||
return approvers;
|
||||
}
|
||||
|
||||
private void collectApproverNodes(JSONObject node, List<JSONObject> result) {
|
||||
if (node == null) {
|
||||
return;
|
||||
}
|
||||
if ("approver".equals(node.getString("type"))) {
|
||||
result.add(node);
|
||||
}
|
||||
JSONObject child = node.getJSONObject("childNode");
|
||||
if (child != null) {
|
||||
collectApproverNodes(child, result);
|
||||
}
|
||||
JSONArray conditionNodes = node.getJSONArray("conditionNodes");
|
||||
if (conditionNodes != null) {
|
||||
for (int i = 0; i < conditionNodes.size(); i++) {
|
||||
Object cn = conditionNodes.get(i);
|
||||
if (cn instanceof JSONObject) {
|
||||
collectApproverNodes((JSONObject) cn, result);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private String resolveDtUserIdWithFallback(String username, String accessToken, int tenantId,
|
||||
Map<String, String> cache) {
|
||||
if (cache.containsKey(username)) {
|
||||
return cache.get(username);
|
||||
}
|
||||
String phone = null;
|
||||
try {
|
||||
List<String> phones = jdbcTemplate.queryForList(
|
||||
"SELECT phone FROM sys_user WHERE username=? AND (del_flag=0 OR del_flag IS NULL) LIMIT 1",
|
||||
String.class, username);
|
||||
if (!phones.isEmpty() && oConvertUtils.isNotEmpty(phones.get(0))) {
|
||||
phone = phones.get(0).trim();
|
||||
}
|
||||
} catch (Exception ignored) {
|
||||
// 忽略
|
||||
}
|
||||
String dtId = resolveDtUserId(username, phone, accessToken, tenantId);
|
||||
cache.put(username, dtId);
|
||||
return dtId;
|
||||
}
|
||||
|
||||
private String resolveDtUserId(String username, String phone, String accessToken, int tenantId) {
|
||||
if (oConvertUtils.isNotEmpty(username)) {
|
||||
try {
|
||||
List<String> dingIds = jdbcTemplate.queryForList(
|
||||
"SELECT ding_user_id FROM sys_user WHERE username=? AND (del_flag=0 OR del_flag IS NULL)"
|
||||
+ " AND ding_user_id IS NOT NULL AND ding_user_id!='' LIMIT 1",
|
||||
String.class, username);
|
||||
if (!dingIds.isEmpty() && oConvertUtils.isNotEmpty(dingIds.get(0))) {
|
||||
return dingIds.get(0);
|
||||
}
|
||||
} catch (Exception ignored) {
|
||||
// 忽略
|
||||
}
|
||||
List<SysThirdAccount> accounts = sysThirdAccountService.listThirdUserIdByUsername(
|
||||
new String[]{username}, "dingtalk", tenantId);
|
||||
if (accounts != null && !accounts.isEmpty() && oConvertUtils.isNotEmpty(accounts.get(0).getThirdUserId())) {
|
||||
return accounts.get(0).getThirdUserId();
|
||||
}
|
||||
}
|
||||
if (oConvertUtils.isNotEmpty(phone)) {
|
||||
Response<String> resp = JdtUserAPI.getUseridByMobile(phone, accessToken);
|
||||
if (resp.isSuccess() && oConvertUtils.isNotEmpty(resp.getResult())) {
|
||||
return resp.getResult();
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
//update-end---author:GHT ---date:20260608 for:【审批注册中心】按MES发起参数构建processForecast请求体-----------
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
package org.jeecg.modules.xslmes.dingtalk.service;
|
||||
|
||||
import java.util.Map;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
import org.jeecg.modules.print.vo.PrintBizFieldItemVO;
|
||||
import org.jeecg.modules.system.service.ISysDictService;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
/**
|
||||
* 审批模板绑定字段取值:支持原值与字典/表字典显示文本。
|
||||
*/
|
||||
@Service
|
||||
@Slf4j
|
||||
public class DingTplBindFieldValueResolver {
|
||||
|
||||
@Autowired private ISysDictService sysDictService;
|
||||
|
||||
/**
|
||||
* 按绑定配置解析字段值。
|
||||
*
|
||||
* @param rowData 业务行数据(Map)
|
||||
* @param bizField 字段路径
|
||||
* @param valueMode raw=原值,text=显示文本
|
||||
* @param meta 字段元数据(含 translateKind)
|
||||
*/
|
||||
public Object resolveValue(
|
||||
Object rowData, String bizField, String valueMode, PrintBizFieldItemVO meta) {
|
||||
Object raw = getNestedValue(rowData, bizField);
|
||||
if (!"text".equalsIgnoreCase(StringUtils.trimToEmpty(valueMode))
|
||||
|| meta == null
|
||||
|| StringUtils.isBlank(meta.getTranslateKind())
|
||||
|| "NONE".equalsIgnoreCase(meta.getTranslateKind())) {
|
||||
return raw;
|
||||
}
|
||||
String fromRow = getDictTextFromRow(rowData, bizField);
|
||||
if (StringUtils.isNotBlank(fromRow)) {
|
||||
return fromRow;
|
||||
}
|
||||
if (raw == null) {
|
||||
return null;
|
||||
}
|
||||
String key = String.valueOf(raw);
|
||||
if (StringUtils.isBlank(key)) {
|
||||
return raw;
|
||||
}
|
||||
try {
|
||||
if ("DICT".equalsIgnoreCase(meta.getTranslateKind())
|
||||
&& StringUtils.isNotBlank(meta.getDictCode())) {
|
||||
String text = sysDictService.queryDictTextByKey(meta.getDictCode(), key);
|
||||
return StringUtils.isNotBlank(text) ? text : raw;
|
||||
}
|
||||
if ("TABLE".equalsIgnoreCase(meta.getTranslateKind())
|
||||
&& StringUtils.isNotBlank(meta.getDictTable())) {
|
||||
String text =
|
||||
sysDictService.queryTableDictTextByKey(
|
||||
meta.getDictTable(),
|
||||
StringUtils.defaultString(meta.getDictText(), ""),
|
||||
StringUtils.defaultIfBlank(meta.getDictCodeField(), "id"),
|
||||
key);
|
||||
return StringUtils.isNotBlank(text) ? text : raw;
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.warn("审批绑定字段翻译失败 bizField={} key={}: {}", bizField, key, e.getMessage());
|
||||
}
|
||||
return raw;
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
private Object getNestedValue(Object obj, String path) {
|
||||
if (obj == null || StringUtils.isBlank(path)) {
|
||||
return null;
|
||||
}
|
||||
String[] parts = path.split("\\.");
|
||||
Object cur = obj;
|
||||
for (String p : parts) {
|
||||
if (cur == null) {
|
||||
return null;
|
||||
}
|
||||
if (cur instanceof Map) {
|
||||
cur = ((Map<String, Object>) cur).get(p);
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
return cur;
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
private String getDictTextFromRow(Object rowData, String bizField) {
|
||||
if (!(rowData instanceof Map) || StringUtils.isBlank(bizField)) {
|
||||
return null;
|
||||
}
|
||||
Map<String, Object> map = (Map<String, Object>) rowData;
|
||||
String[] parts = bizField.split("\\.");
|
||||
if (parts.length == 1) {
|
||||
Object v = map.get(parts[0] + "_dictText");
|
||||
return v != null ? String.valueOf(v) : null;
|
||||
}
|
||||
String parentPath = String.join(".", java.util.Arrays.copyOf(parts, parts.length - 1));
|
||||
Object parent = getNestedValue(rowData, parentPath);
|
||||
if (parent instanceof Map) {
|
||||
Object v = ((Map<?, ?>) parent).get(parts[parts.length - 1] + "_dictText");
|
||||
return v != null ? String.valueOf(v) : null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
package org.jeecg.modules.xslmes.dingtalk.stream;
|
||||
|
||||
import com.alibaba.fastjson2.JSONObject;
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.jeecg.common.util.oConvertUtils;
|
||||
import org.jeecg.modules.xslmes.approval.constant.ApprovalRecordConstants;
|
||||
import org.jeecg.modules.xslmes.approval.entity.MesXslApprovalRecord;
|
||||
import org.jeecg.modules.xslmes.approval.service.IMesXslApprovalRecordService;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.scheduling.annotation.Scheduled;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.util.Date;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 钉钉审批回调补偿扫描器。
|
||||
* <p>
|
||||
* Stream 模式仅保证"连接期间"的事件推送。服务重启、网络闪断或钉钉侧推送失败
|
||||
* 均可能导致事件静默丢失,造成审批台账长期停留在 RUNNING 状态。
|
||||
* <p>
|
||||
* 本定时任务每 {@link #SWEEP_INTERVAL_MS} 毫秒扫描一次 RUNNING 的钉钉台账,
|
||||
* 主动调用钉钉 API 拉取最新实例状态:
|
||||
* <ul>
|
||||
* <li>钉钉已终态(COMPLETED/TERMINATED)→ 构造合成事件调用 {@link DingBpmsEventProcessor#onInstanceChange}</li>
|
||||
* <li>钉钉仍 RUNNING 但中间节点已同意、MES 集成未执行 → 调用 {@link DingBpmsEventProcessor#reconcileIntermediateNodes}</li>
|
||||
* </ul>
|
||||
* 处理器内部已有幂等保护,重复调用安全。
|
||||
*
|
||||
* @author GHT
|
||||
* @date 2026-06-09 for:【钉钉Stream补偿扫描】漏推回调自动修复
|
||||
*/
|
||||
@Slf4j
|
||||
@Component
|
||||
public class DingApprovalReconcileScheduler {
|
||||
|
||||
private static final String LOG_TAG = DingTalkStreamSdkRunner.LOG_TAG;
|
||||
|
||||
/** 每次扫描最多处理的台账条数(避免单次扫描耗时过长 + DingTalk API 限速) */
|
||||
private static final int MAX_RECORDS_PER_SWEEP = 30;
|
||||
|
||||
/** 两次扫描之间的间隔(毫秒),fixedDelay 保证上次扫描完成后再计时 */
|
||||
private static final long SWEEP_INTERVAL_MS = 3 * 60 * 1000L;
|
||||
|
||||
/**
|
||||
* 发起审批后的最短等待时间(毫秒),防止与 Stream 事件正常到达竞争。
|
||||
* 5 分钟内刚发起的审批不扫描,给 Stream 事件足够的到达时间。
|
||||
*/
|
||||
private static final long MIN_AGE_MS = 5 * 60 * 1000L;
|
||||
|
||||
/** DingTalk API 调用之间的间隔(毫秒),避免触发速率限制 */
|
||||
private static final long API_CALL_INTERVAL_MS = 300L;
|
||||
|
||||
@Autowired
|
||||
private IMesXslApprovalRecordService approvalRecordService;
|
||||
|
||||
@Autowired
|
||||
private DingTalkWorkflowService workflowService;
|
||||
|
||||
@Autowired
|
||||
private DingBpmsEventProcessor eventProcessor;
|
||||
|
||||
@Autowired
|
||||
private DingTalkStreamNodeConfigService nodeConfigService;
|
||||
|
||||
//update-begin---author:GHT ---date:20260609 for:【钉钉Stream补偿扫描】漏推回调自动修复-----
|
||||
@Scheduled(fixedDelay = SWEEP_INTERVAL_MS)
|
||||
public void reconcile() {
|
||||
if (!nodeConfigService.isThisNodeReceiver()) {
|
||||
return;
|
||||
}
|
||||
long sweepStart = System.currentTimeMillis();
|
||||
Date cutoff = new Date(sweepStart - MIN_AGE_MS);
|
||||
|
||||
List<MesXslApprovalRecord> pendingRecords = approvalRecordService.list(
|
||||
new LambdaQueryWrapper<MesXslApprovalRecord>()
|
||||
.eq(MesXslApprovalRecord::getStatus, ApprovalRecordConstants.STATUS_RUNNING)
|
||||
.eq(MesXslApprovalRecord::getChannel, ApprovalRecordConstants.CHANNEL_DINGTALK)
|
||||
.isNotNull(MesXslApprovalRecord::getExternalInstanceId)
|
||||
.ne(MesXslApprovalRecord::getExternalInstanceId, "")
|
||||
.lt(MesXslApprovalRecord::getApplyTime, cutoff)
|
||||
.orderByAsc(MesXslApprovalRecord::getApplyTime)
|
||||
.last("LIMIT " + MAX_RECORDS_PER_SWEEP));
|
||||
|
||||
if (pendingRecords.isEmpty()) {
|
||||
log.debug("{} 补偿扫描:无待检查台账", LOG_TAG);
|
||||
return;
|
||||
}
|
||||
|
||||
log.info("{} 补偿扫描开始,待检台账数={}", LOG_TAG, pendingRecords.size());
|
||||
int compensated = 0;
|
||||
int nodeCompensated = 0;
|
||||
int skipped = 0;
|
||||
int failed = 0;
|
||||
|
||||
for (MesXslApprovalRecord record : pendingRecords) {
|
||||
String instanceId = record.getExternalInstanceId();
|
||||
try {
|
||||
JSONObject instance = workflowService.getProcessInstance(instanceId);
|
||||
if (instance == null) {
|
||||
log.warn("{} 补偿扫描:拉取实例失败 instanceId={} recordId={}",
|
||||
LOG_TAG, instanceId, record.getId());
|
||||
failed++;
|
||||
continue;
|
||||
}
|
||||
|
||||
JSONObject syntheticEvent = buildSyntheticEvent(instance, instanceId);
|
||||
if (syntheticEvent != null) {
|
||||
log.info("{} 补偿扫描:检测到漏推终态事件,触发补偿 instanceId={} dingStatus={} dingResult={}",
|
||||
LOG_TAG, instanceId,
|
||||
instance.getString("status"), instance.getString("result"));
|
||||
eventProcessor.onInstanceChange(syntheticEvent);
|
||||
compensated++;
|
||||
} else if ("RUNNING".equalsIgnoreCase(instance.getString("status"))) {
|
||||
//update-begin---author:GHT ---date:20260609 for:【钉钉Stream补偿扫描】RUNNING态补中间节点集成-----------
|
||||
int nodes = eventProcessor.reconcileIntermediateNodes(record, instance);
|
||||
if (nodes > 0) {
|
||||
nodeCompensated += nodes;
|
||||
log.info("{} 补偿扫描:中间节点已补偿 instanceId={} recordId={} nodes={}",
|
||||
LOG_TAG, instanceId, record.getId(), nodes);
|
||||
} else {
|
||||
log.debug("{} 补偿扫描:RUNNING且无待补中间节点 instanceId={}", LOG_TAG, instanceId);
|
||||
skipped++;
|
||||
}
|
||||
//update-end---author:GHT ---date:20260609 for:【钉钉Stream补偿扫描】RUNNING态补中间节点集成-----------
|
||||
} else {
|
||||
log.debug("{} 补偿扫描:非RUNNING且无法映射终态,跳过 instanceId={} dingStatus={}",
|
||||
LOG_TAG, instanceId, instance.getString("status"));
|
||||
skipped++;
|
||||
}
|
||||
|
||||
} catch (Exception e) {
|
||||
log.error("{} 补偿扫描:处理异常 instanceId={} recordId={}: {}",
|
||||
LOG_TAG, instanceId, record.getId(), e.getMessage(), e);
|
||||
failed++;
|
||||
}
|
||||
|
||||
// 避免连续 API 调用触发 DingTalk 限速
|
||||
sleepQuietly(API_CALL_INTERVAL_MS);
|
||||
}
|
||||
|
||||
log.info("{} 补偿扫描完成 总数={} 终态补偿={} 中间节点补偿={} 仍跳过={} 失败={} costMs={}",
|
||||
LOG_TAG, pendingRecords.size(), compensated, nodeCompensated, skipped, failed,
|
||||
System.currentTimeMillis() - sweepStart);
|
||||
}
|
||||
|
||||
/**
|
||||
* 将钉钉实例状态映射为 {@code onInstanceChange} 所需的合成事件。
|
||||
* <ul>
|
||||
* <li>COMPLETED + agree → type=finish, result=agree</li>
|
||||
* <li>COMPLETED + refuse → type=finish, result=refuse</li>
|
||||
* <li>TERMINATED/CANCELED → type=terminate</li>
|
||||
* <li>RUNNING 或未知 → null(不补偿)</li>
|
||||
* </ul>
|
||||
*/
|
||||
private JSONObject buildSyntheticEvent(JSONObject instance, String processInstanceId) {
|
||||
String dingStatus = instance.getString("status");
|
||||
String dingResult = instance.getString("result");
|
||||
|
||||
if ("RUNNING".equalsIgnoreCase(dingStatus)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
JSONObject event = new JSONObject();
|
||||
event.put("processInstanceId", processInstanceId);
|
||||
|
||||
if ("COMPLETED".equalsIgnoreCase(dingStatus)) {
|
||||
if ("agree".equalsIgnoreCase(dingResult)) {
|
||||
event.put("type", "finish");
|
||||
event.put("result", "agree");
|
||||
} else if ("refuse".equalsIgnoreCase(dingResult)) {
|
||||
event.put("type", "finish");
|
||||
event.put("result", "refuse");
|
||||
} else {
|
||||
// redirect/转交等非终态结果,跳过(onInstanceChange 内部会跳过这类result)
|
||||
event.put("type", "finish");
|
||||
event.put("result", oConvertUtils.isEmpty(dingResult) ? "unknown" : dingResult);
|
||||
}
|
||||
return event;
|
||||
}
|
||||
|
||||
if ("TERMINATED".equalsIgnoreCase(dingStatus) || "CANCELED".equalsIgnoreCase(dingStatus)) {
|
||||
event.put("type", "terminate");
|
||||
return event;
|
||||
}
|
||||
|
||||
log.info("{} 补偿扫描:未知钉钉状态 dingStatus={} instanceId={}", LOG_TAG, dingStatus, processInstanceId);
|
||||
return null;
|
||||
}
|
||||
|
||||
private void sleepQuietly(long ms) {
|
||||
try {
|
||||
Thread.sleep(ms);
|
||||
} catch (InterruptedException e) {
|
||||
Thread.currentThread().interrupt();
|
||||
}
|
||||
}
|
||||
//update-end---author:GHT ---date:20260609 for:【钉钉Stream补偿扫描】漏推回调自动修复-----
|
||||
}
|
||||
@@ -13,6 +13,12 @@ import org.jeecg.modules.xslmes.approval.entity.MesXslApprovalFlow;
|
||||
import org.jeecg.modules.xslmes.approval.entity.MesXslApprovalRecord;
|
||||
import org.jeecg.modules.xslmes.approval.service.IMesXslApprovalFlowService;
|
||||
import org.jeecg.modules.xslmes.approval.service.IMesXslApprovalGateService;
|
||||
import org.jeecg.modules.xslmes.approval.integration.engine.ApprovalInstanceStageExtractor;
|
||||
import org.jeecg.modules.xslmes.approval.integration.engine.ApprovalInstanceStageExtractor.NodePair;
|
||||
import org.jeecg.modules.xslmes.approval.integration.engine.ApprovalInstanceStageExtractor.NodeTaskDecision;
|
||||
import org.jeecg.modules.xslmes.approval.integration.engine.ApprovalInstanceStageExtractor.StageCompletion;
|
||||
import org.jeecg.modules.xslmes.approval.integration.entity.MesXslIntegrationLog;
|
||||
import org.jeecg.modules.xslmes.approval.integration.service.IMesXslIntegrationLogService;
|
||||
import org.jeecg.modules.xslmes.approval.service.IMesXslApprovalRecordService;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
@@ -20,6 +26,7 @@ import org.springframework.stereotype.Component;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* 钉钉审批事件处理器。
|
||||
@@ -39,6 +46,8 @@ import java.util.List;
|
||||
@Component
|
||||
public class DingBpmsEventProcessor {
|
||||
|
||||
private static final String LOG_TAG = DingTalkStreamSdkRunner.LOG_TAG;
|
||||
|
||||
@Autowired
|
||||
private IMesXslApprovalGateService approvalGateService;
|
||||
@Autowired
|
||||
@@ -55,19 +64,36 @@ public class DingBpmsEventProcessor {
|
||||
@Autowired
|
||||
private JdbcTemplate jdbcTemplate;
|
||||
|
||||
@Autowired
|
||||
private ApprovalInstanceStageExtractor instanceStageExtractor;
|
||||
|
||||
@Autowired
|
||||
private DingStreamCallbackLogHelper callbackLogHelper;
|
||||
|
||||
@Autowired
|
||||
private IMesXslIntegrationLogService integrationLogService;
|
||||
|
||||
// ==================== bpms_instance_change ====================
|
||||
|
||||
//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,184 +108,507 @@ 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;
|
||||
}
|
||||
|
||||
//update-begin---author:GHT ---date:2026-06-04 for:【20260604】钉钉回调幂等去重:finishByExternalInstance条件为status=RUNNING,0行更新即终态已处理-----
|
||||
// ② 更新台账(乐观条件:WHERE status=RUNNING;返回 false 表示已是终态,本次为重复事件,直接跳过)
|
||||
log.info("{} bpms_instance_change 映射终态 instanceId={} mesStatus={} remark={}",
|
||||
LOG_TAG, processInstanceId, status, remark);
|
||||
|
||||
//update-begin---author:GHT ---date:20260609 for:【驳回回退】台账已是终态时仍补偿触发业务回调,避免集成未执行-----------
|
||||
try {
|
||||
boolean updated = approvalGateService.finishByExternalInstance(
|
||||
ApprovalRecordConstants.CHANNEL_DINGTALK, processInstanceId, status, remark);
|
||||
if (!updated) {
|
||||
log.info("[DingBpms] instanceId={} 台账已是终态,跳过重复的终态事件", processInstanceId);
|
||||
return;
|
||||
if (updated) {
|
||||
log.info("{} 台账已更新 instanceId={} -> status={}", LOG_TAG, processInstanceId, status);
|
||||
} else {
|
||||
MesXslApprovalRecord existing = findRecord(processInstanceId);
|
||||
if (existing != null && status.equals(existing.getStatus())) {
|
||||
log.info("{} bpms_instance_change 台账已是终态({}),补偿触发业务/集成回调 instanceId={}",
|
||||
LOG_TAG, status, processInstanceId);
|
||||
} else {
|
||||
log.info("{} bpms_instance_change 跳过:台账已是终态(状态不一致或重复事件) instanceId={}",
|
||||
LOG_TAG, processInstanceId);
|
||||
return;
|
||||
}
|
||||
}
|
||||
log.info("[DingBpms] 台账已更新 instanceId={} -> status={}", 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=RUNNING,0行更新即终态已处理-----
|
||||
//update-end---author:GHT ---date:20260609 for:【驳回回退】台账已是终态时仍补偿触发业务/集成回调,避免集成未执行-----------
|
||||
|
||||
//update-begin---author:GHT ---date:2026-06-08 for:【风险修复-R5】TERMINATED时触发fireCancelled,允许业务回滚中间态-----------
|
||||
if (ApprovalRecordConstants.STATUS_CANCELLED.equals(status)) {
|
||||
MesXslApprovalRecord cancelledRecord = findRecord(processInstanceId);
|
||||
if (cancelledRecord != null && oConvertUtils.isNotEmpty(cancelledRecord.getBizTable())) {
|
||||
ApprovalCallbackContext cancelCtx = buildContext(cancelledRecord, remark, null);
|
||||
logCallbackDispatch("fireCancelled", cancelCtx);
|
||||
try {
|
||||
callbackDispatcher.fireCancelled(cancelCtx);
|
||||
//update-begin---author:GHT ---date:20260609 for:【钉钉回调日志】终止态回调已触发-----------
|
||||
callbackLogHelper.markProcessed("审批终止,已触发 fireCancelled", cancelledRecord);
|
||||
//update-end---author:GHT ---date:20260609 for:【钉钉回调日志】终止态回调已触发-----------
|
||||
} catch (Exception e) {
|
||||
log.error("{} fireCancelled 失败 instanceId={}: {}", LOG_TAG, processInstanceId, e.getMessage(), e);
|
||||
}
|
||||
}
|
||||
log.info("{} bpms_instance_change 终止态处理完成 instanceId={}", LOG_TAG, processInstanceId);
|
||||
return;
|
||||
}
|
||||
//update-end---author:GHT ---date:2026-06-08 for:【风险修复-R5】TERMINATED时触发fireCancelled,允许业务回滚中间态-----------
|
||||
|
||||
// ③ 拉取完整审批实例
|
||||
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());
|
||||
|
||||
ApprovalCallbackContext ctx = buildContext(record, remark);
|
||||
log.info("{} 实例解析 instanceId={} taskOpCount={} mesApproverNodeCount={} mesNodeNames={} taskOps={}",
|
||||
LOG_TAG, processInstanceId, taskOps.size(), mesNodes.size(),
|
||||
summarizeNodeNames(mesNodes), summarizeTaskOps(taskOps));
|
||||
|
||||
// token 在后续 approve/reject 分支中按实际操作人生成后注入 ctx,此处先以 null 初始化
|
||||
ApprovalCallbackContext ctx = buildContext(record, remark, null);
|
||||
|
||||
//update-begin---author:GHT ---date:2026-06-08 for:【缺陷修复-D2】用activityId替代mesNodes索引定位终态节点,支持条件分支场景-----------
|
||||
MesXslApprovalFlow flow = loadFlow(record.getFlowId());
|
||||
String flowConfig = (flow != null) ? flow.getFlowConfig() : null;
|
||||
|
||||
if (ApprovalRecordConstants.STATUS_APPROVED.equals(status)) {
|
||||
// 最终通过:执行最后一个节点的 onApprove(用最后审批人的 token)
|
||||
if (!mesNodes.isEmpty() && !taskOps.isEmpty()) {
|
||||
JSONObject lastNode = null;
|
||||
String lastDtUserId = null;
|
||||
if (!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);
|
||||
lastDtUserId = lastOp.getString("userId");
|
||||
String lastActivityId = lastOp.getString("activityId");
|
||||
// 优先用 activityId 精确定位(修复缺陷2)
|
||||
if (oConvertUtils.isNotEmpty(lastActivityId) && oConvertUtils.isNotEmpty(flowConfig)) {
|
||||
lastNode = findNodeByActivityId(flowConfig, lastActivityId);
|
||||
}
|
||||
// 降级:无 activityId 时回退到索引
|
||||
if (lastNode == null && !mesNodes.isEmpty()) {
|
||||
lastNode = mesNodes.get(mesNodes.size() - 1);
|
||||
}
|
||||
} else if (!mesNodes.isEmpty()) {
|
||||
lastNode = mesNodes.get(mesNodes.size() - 1);
|
||||
}
|
||||
String token = workflowService.generateTokenByDtUserId(lastDtUserId);
|
||||
ctx.setToken(token);
|
||||
if (lastNode != null) {
|
||||
ctx.setNodeId(lastNode.getString("id")).setNodeName(lastNode.getString("name"));
|
||||
JSONObject nodeProps = lastNode.getJSONObject("props");
|
||||
if (nodeProps != null) ctx.setStageKey(nodeProps.getString("stageKey"));
|
||||
}
|
||||
log.info("{} 终态通过:最后节点 nodeId={} nodeName={} dtUserId={} tokenGenerated={}",
|
||||
LOG_TAG, ctx.getNodeId(), ctx.getNodeName(), lastDtUserId, oConvertUtils.isNotEmpty(token));
|
||||
//update-begin---author:GHT ---date:20260608 for:【缺陷修复-会签集成-R7】bpms_instance_change先于bpms_task_change处理时,R6会跳过最后节点的节点回调;此处补偿触发,确保ON_NODE_APPROVE集成方案正常执行,幂等key(recordId+actionId)保证双路只执行一次-----------
|
||||
logCallbackDispatch("fireNodeApproved (终态兜底-最终节点)", ctx);
|
||||
callbackDispatcher.fireNodeApproved(ctx);
|
||||
//update-end---author:GHT ---date:20260608 for:【缺陷修复-会签集成-R7】bpms_instance_change先于bpms_task_change处理时,R6会跳过最后节点的节点回调;此处补偿触发,确保ON_NODE_APPROVE集成方案正常执行,幂等key(recordId+actionId)保证双路只执行一次-----------
|
||||
logCallbackDispatch("fireApproved", ctx);
|
||||
callbackDispatcher.fireApproved(ctx);
|
||||
|
||||
} else {
|
||||
// 驳回:复用与 MES 内部审批相同的 isBizAtOriginStatus 逻辑
|
||||
// 台账已在发起时快照了 originStatus;若单据状态仍为原值,说明未被推进,跳过 onReject
|
||||
JSONObject refuseOp = findRefuseOp(taskOps);
|
||||
JSONObject refuseNode = null;
|
||||
String refuseDtUserId = null;
|
||||
if (refuseOp != null) {
|
||||
int refuseIndex = taskOps.indexOf(refuseOp);
|
||||
boolean bizAtOrigin = isBizAtOriginStatus(record);
|
||||
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());
|
||||
}
|
||||
} else {
|
||||
log.info("[DingBpms] 单据仍处于发起前原始状态,跳过 onReject 回调 instanceId={}",
|
||||
processInstanceId);
|
||||
refuseDtUserId = refuseOp.getString("userId");
|
||||
String refuseActivityId = refuseOp.getString("activityId");
|
||||
// 优先用 activityId 精确定位(修复缺陷2)
|
||||
if (oConvertUtils.isNotEmpty(refuseActivityId) && oConvertUtils.isNotEmpty(flowConfig)) {
|
||||
refuseNode = findNodeByActivityId(flowConfig, refuseActivityId);
|
||||
}
|
||||
// 降级:无 activityId 时回退到索引
|
||||
if (refuseNode == null) {
|
||||
int refuseIndex = taskOps.indexOf(refuseOp);
|
||||
if (refuseIndex >= 0 && refuseIndex < mesNodes.size()) {
|
||||
refuseNode = mesNodes.get(refuseIndex);
|
||||
}
|
||||
}
|
||||
boolean bizAtOrigin = isBizAtOriginStatus(record);
|
||||
String currentBizStatus = readBizStatus(record);
|
||||
log.info("{} 终态驳回:refuseActivityId={} refuseNodeId={} bizAtOrigin={} originStatus={} currentBizStatus={}",
|
||||
LOG_TAG, refuseActivityId, refuseNode != null ? refuseNode.getString("id") : null,
|
||||
bizAtOrigin, record.getOriginStatus(), currentBizStatus);
|
||||
} else {
|
||||
log.info("{} 终态驳回:operationRecords 中未找到 REFUSE 记录 instanceId={}", LOG_TAG, processInstanceId);
|
||||
}
|
||||
String token = workflowService.generateTokenByDtUserId(refuseDtUserId);
|
||||
ctx.setToken(token);
|
||||
if (refuseNode != null) {
|
||||
ctx.setNodeId(refuseNode.getString("id")).setNodeName(refuseNode.getString("name"));
|
||||
JSONObject nodeProps = refuseNode.getJSONObject("props");
|
||||
if (nodeProps != null) ctx.setStageKey(nodeProps.getString("stageKey"));
|
||||
}
|
||||
logCallbackDispatch("fireRejected", ctx);
|
||||
callbackDispatcher.fireRejected(ctx);
|
||||
}
|
||||
//update-end---author:GHT ---date:2026-06-08 for:【缺陷修复-D2】用activityId替代mesNodes索引定位终态节点,支持条件分支场景-----------
|
||||
|
||||
log.info("[DingBpms] 终态回调完成 bizTable={} bizDataId={} status={}",
|
||||
record.getBizTable(), record.getBizDataId(), status);
|
||||
//update-begin---author:GHT ---date:20260609 for:【钉钉回调日志】终态回调已触发-----------
|
||||
String processRemark = ApprovalRecordConstants.STATUS_APPROVED.equals(status)
|
||||
? "审批通过,已触发 fireNodeApproved/fireApproved"
|
||||
: "审批驳回,已触发 fireRejected";
|
||||
callbackLogHelper.markProcessed(processRemark, record);
|
||||
//update-end---author:GHT ---date:20260609 for:【钉钉回调日志】终态回调已触发-----------
|
||||
|
||||
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");
|
||||
//update-begin---author:GHT ---date:20260608 for:【钉钉Stream回调】兼容staffId/actualActionerId字段(Stream事件无actionerUserId)-----------
|
||||
String actionerDtUserId = resolveActionerDtUserId(data);
|
||||
String eventActivityId = data.getString("activityId");
|
||||
//update-end---author:GHT ---date:20260608 for:【钉钉Stream回调】兼容staffId/actualActionerId字段(Stream事件无actionerUserId)-----------
|
||||
|
||||
log.info("[DingBpms] bpms_task_change instanceId={} type={} result={} actionerUserId={}",
|
||||
processInstanceId, type, result, actionerDtUserId);
|
||||
log.info("{} bpms_task_change 入参 instanceId={} type={} result={} actionerUserId={} activityId={} payload={}",
|
||||
LOG_TAG, processInstanceId, type, result, actionerDtUserId, eventActivityId, data.toJSONString());
|
||||
|
||||
// 只处理节点"完成-通过"
|
||||
if (!"finish".equals(type) || !"agree".equals(result)) {
|
||||
// 拒绝终态由 bpms_instance_change 统一处理,此处不重复触发
|
||||
if (!"finish".equals(type)) {
|
||||
log.info("{} bpms_task_change 跳过:type={} 非节点结束事件(finish) instanceId={}",
|
||||
LOG_TAG, type, processInstanceId);
|
||||
return;
|
||||
}
|
||||
//update-begin---author:GHT ---date:20260609 for:【驳回回退】task_change拒绝时主动触发终态处理,不依赖 instance_change-----------
|
||||
if ("refuse".equals(result)) {
|
||||
log.info("{} bpms_task_change 收到拒绝,转交 onInstanceChange(instance_change 可能未推送) instanceId={}",
|
||||
LOG_TAG, processInstanceId);
|
||||
JSONObject instanceData = new JSONObject(data);
|
||||
instanceData.put("type", "finish");
|
||||
instanceData.put("result", "refuse");
|
||||
onInstanceChange(instanceData);
|
||||
return;
|
||||
}
|
||||
//update-end---author:GHT ---date:20260609 for:【驳回回退】task_change拒绝时主动触发终态处理,不依赖 instance_change-----------
|
||||
if (!"agree".equals(result)) {
|
||||
log.info("{} bpms_task_change 跳过:result={} 非同意,redirect 由 bpms_instance_change 处理 instanceId={}",
|
||||
LOG_TAG, result, processInstanceId);
|
||||
return;
|
||||
}
|
||||
|
||||
MesXslApprovalRecord record = findRecord(processInstanceId);
|
||||
if (record == null || oConvertUtils.isEmpty(record.getBizTable())) {
|
||||
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;
|
||||
}
|
||||
|
||||
// 拉取实例详情
|
||||
//update-begin---author:GHT ---date:2026-06-08 for:【风险修复-R6】bpms_instance_change可能已将台账置为终态;此时ON_NODE_APPROVE由onInstanceChange中R7补偿触发(fireNodeApproved),此处安全跳过避免重复。-----------
|
||||
if (!ApprovalRecordConstants.STATUS_RUNNING.equals(record.getStatus())) {
|
||||
log.info("{} bpms_task_change 跳过:台账已是终态 status={} instanceId={} recordId={} (ON_NODE_APPROVE已由onInstanceChange补偿触发)",
|
||||
LOG_TAG, record.getStatus(), processInstanceId, record.getId());
|
||||
return;
|
||||
}
|
||||
//update-end---author:GHT ---date:2026-06-08 for:【风险修复-R6】bpms_instance_change可能已将台账置为终态;此时ON_NODE_APPROVE由onInstanceChange中R7补偿触发(fireNodeApproved),此处安全跳过避免重复。-----------
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
//update-begin---author:GHT ---date:2026-06-08 for:【缺陷修复-D4】检测审批退回(REDIRECT_PROCESS),记录警告供排查(退回后重审节点受幂等保护,属已知限制)-----------
|
||||
if (workflowService.hasRedirectProcess(instance)) {
|
||||
log.warn("{} bpms_task_change 检测到审批退回(REDIRECT_PROCESS) instanceId={}。" +
|
||||
"activityId方案已正确定位节点;退回后重审同一节点会被tryMarkNodeProcessed幂等拦截,属已知限制。",
|
||||
LOG_TAG, processInstanceId);
|
||||
}
|
||||
//update-end---author:GHT ---date:2026-06-08 for:【缺陷修复-D4】检测审批退回(REDIRECT_PROCESS),记录警告供排查-----------
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
// 刚完成的是最后一条操作(index = taskOps.size()-1)
|
||||
int nodeIndex = taskOps.size() - 1;
|
||||
if (nodeIndex >= mesNodes.size()) {
|
||||
log.debug("[DingBpms] 节点索引 {} 超出 MES 节点数 {},跳过", nodeIndex, mesNodes.size());
|
||||
//update-begin---author:GHT ---date:2026-06-08 for:【缺陷修复-D2/D3】用activityId+tasks[]定位节点:activityId在operationRecords中的顺序位置映射MES节点(修正activityId≠MES节点id的根本缺陷)-----------
|
||||
// 优先用 Stream 事件自带的 activityId;缺失时再从 operationRecords 按 staffId 反查
|
||||
String activityId = oConvertUtils.isNotEmpty(eventActivityId)
|
||||
? eventActivityId
|
||||
: workflowService.resolveActivityIdForEvent(instance, actionerDtUserId);
|
||||
if (oConvertUtils.isEmpty(activityId)) {
|
||||
log.warn("{} bpms_task_change 跳过:无法解析 activityId staffId={} eventActivityId={} instanceId={}",
|
||||
LOG_TAG, actionerDtUserId, eventActivityId, processInstanceId);
|
||||
return;
|
||||
}
|
||||
|
||||
//update-begin---author:GHT ---date:20260608 for:【审批注册中心】节点映射改用实例tasks的activityId顺序-----------
|
||||
// 用 tasks[] 中 activityId 首次出现顺序映射 MES DFS 节点(与审批实例节点展示一致,避免 operationRecords 顺序偏差)
|
||||
int stepIndex = instanceStageExtractor.resolveStepIndexFromTasks(instance, activityId);
|
||||
if (stepIndex < 0) {
|
||||
log.warn("{} bpms_task_change 跳过:无法在 tasks 中确定 activityId={} 的步骤序号 instanceId={}",
|
||||
LOG_TAG, activityId, processInstanceId);
|
||||
return;
|
||||
}
|
||||
//update-end---author:GHT ---date:20260608 for:【审批注册中心】节点映射改用实例tasks的activityId顺序-----------
|
||||
if (stepIndex >= mesNodes.size()) {
|
||||
log.warn("{} bpms_task_change 跳过:stepIndex={} 超出 MES 节点数={} activityId={} instanceId={}",
|
||||
LOG_TAG, stepIndex, mesNodes.size(), activityId, processInstanceId);
|
||||
return;
|
||||
}
|
||||
JSONObject node = mesNodes.get(stepIndex);
|
||||
int nodeIndex = stepIndex;
|
||||
|
||||
//update-begin---author:GHT ---date:20260608 for:【钉钉Stream回调】会签节点完成后再触发集成(刷新实例+multiMode兜底)-----------
|
||||
//update-begin---author:GHT ---date:20260608 for:【缺陷修复-会签集成】传入actionerDtUserId以修复AND模式API延迟竞态-----------
|
||||
JSONObject completedInstance = workflowService.resolveInstanceWhenNodeComplete(
|
||||
processInstanceId, instance, activityId, node, actionerDtUserId);
|
||||
//update-end---author:GHT ---date:20260608 for:【缺陷修复-会签集成】传入actionerDtUserId以修复AND模式API延迟竞态-----------
|
||||
if (completedInstance == null) {
|
||||
log.info("{} bpms_task_change 节点仍有待处理任务,等待会签/依次审批完成 activityId={} instanceId={}",
|
||||
LOG_TAG, activityId, processInstanceId);
|
||||
return;
|
||||
}
|
||||
instance = completedInstance;
|
||||
taskOps = workflowService.getTaskOperations(instance);
|
||||
//update-end---author:GHT ---date:20260608 for:【钉钉Stream回调】会签节点完成后再触发集成(刷新实例+multiMode兜底)-----------
|
||||
//update-end---author:GHT ---date:2026-06-08 for:【缺陷修复-D2/D3】用activityId+tasks[]定位节点:activityId在operationRecords中的顺序位置映射MES节点-----------
|
||||
|
||||
//update-begin---author:GHT ---date:2026-06-04 for:【20260604】钉钉回调幂等去重:DB乐观锁推进processed_op_count,并发安全且重启不丢-----
|
||||
// tryMarkNodeProcessed:UPDATE ... 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 跳过:节点 activityId={} 已处理(幂等 nodeIndex={}) instanceId={} recordId={}",
|
||||
LOG_TAG, activityId, nodeIndex, processInstanceId, record.getId());
|
||||
return;
|
||||
}
|
||||
log.info("{} 节点幂等占位成功 nodeIndex={} activityId={} recordId={} instanceId={}",
|
||||
LOG_TAG, nodeIndex, activityId, 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;
|
||||
String actioner = oConvertUtils.isNotEmpty(lastOp.getString("showName"))
|
||||
? lastOp.getString("showName") : "审批人";
|
||||
//update-begin---author:GHT ---date:20260608 for:【审批注册中心】会签节点反写全部审批人及最新完成时间-----------
|
||||
StageCompletion activityCompletion = instanceStageExtractor.extractActivityCompletion(instance, activityId, node);
|
||||
String dtUserId = actionerDtUserId;
|
||||
String actioner = "审批人";
|
||||
if (activityCompletion != null) {
|
||||
if (oConvertUtils.isNotEmpty(activityCompletion.getOperatorBy())) {
|
||||
actioner = activityCompletion.getOperatorBy();
|
||||
}
|
||||
if (activityCompletion.getDtUserIds() != null && !activityCompletion.getDtUserIds().isEmpty()) {
|
||||
dtUserId = activityCompletion.getDtUserIds().get(activityCompletion.getDtUserIds().size() - 1);
|
||||
}
|
||||
} else {
|
||||
JSONObject lastOp = taskOps.get(taskOps.size() - 1);
|
||||
dtUserId = oConvertUtils.isNotEmpty(lastOp.getString("userId"))
|
||||
? lastOp.getString("userId") : actionerDtUserId;
|
||||
actioner = oConvertUtils.isNotEmpty(lastOp.getString("showName"))
|
||||
? lastOp.getString("showName") : "审批人";
|
||||
}
|
||||
//update-end---author:GHT ---date:20260608 for:【审批注册中心】会签节点反写全部审批人及最新完成时间-----------
|
||||
|
||||
//update-begin---author:GHT ---date:2026-06-08 for:【缺陷修复-D1】生成token并注入ApprovalCallbackContext,透传真实审批人身份-----------
|
||||
String token = workflowService.generateTokenByDtUserId(dtUserId);
|
||||
JSONObject node = mesNodes.get(nodeIndex);
|
||||
//update-end---author:GHT ---date:2026-06-08 for:【缺陷修复-D1】生成token并注入ApprovalCallbackContext,透传真实审批人身份-----------
|
||||
|
||||
// 执行该节点的 onNodeApprove HTTP 回调
|
||||
actionHttpExecutor.run(node, "onNodeApprove", record.getBizDataId(), token);
|
||||
JSONObject nodeProps = node.getJSONObject("props");
|
||||
String stageKey = (nodeProps != null) ? nodeProps.getString("stageKey") : null;
|
||||
log.info("{} 节点映射 nodeIndex={} activityId={} nodeId={} nodeName={} stageKey={} actioner={} dtUserId={} tokenGenerated={}",
|
||||
LOG_TAG, nodeIndex, activityId, node.getString("id"), node.getString("name"),
|
||||
stageKey, actioner, dtUserId, oConvertUtils.isNotEmpty(token));
|
||||
|
||||
// 触发 IApprovalBizCallback.onNodeApproved
|
||||
try {
|
||||
ApprovalCallbackContext ctx = buildContext(record, "钉钉节点审批通过(" + actioner + ")")
|
||||
.setOperatorName(actioner);
|
||||
ApprovalCallbackContext ctx = buildContext(record, "钉钉节点审批通过(" + actioner + ")", token)
|
||||
.setOperatorName(actioner)
|
||||
.setOperatorTime(activityCompletion != null ? activityCompletion.getOperatorTime() : null)
|
||||
.setNodeId(node.getString("id"))
|
||||
.setNodeName(node.getString("name"))
|
||||
.setStageKey(stageKey)
|
||||
.setActivityId(activityId);
|
||||
logCallbackDispatch("fireNodeApproved", ctx);
|
||||
callbackDispatcher.fireNodeApproved(ctx);
|
||||
//update-begin---author:GHT ---date:20260609 for:【钉钉回调日志】节点通过回调已触发-----------
|
||||
callbackLogHelper.markProcessed("节点审批通过,已触发 fireNodeApproved", record);
|
||||
//update-end---author:GHT ---date:20260609 for:【钉钉回调日志】节点通过回调已触发-----------
|
||||
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 完成 nodeIndex={} activityId={} actioner={} bizTable={} bizDataId={} instanceId={}",
|
||||
LOG_TAG, nodeIndex, activityId, actioner,
|
||||
record.getBizTable(), record.getBizDataId(), processInstanceId);
|
||||
}
|
||||
//update-end---author:GHT ---date:20260604 for:【钉钉Stream回调】节点通过时按operationRecords索引执行onNodeApprove-----
|
||||
|
||||
//update-begin---author:GHT ---date:20260609 for:【钉钉Stream补偿扫描】RUNNING态补中间节点集成-----------
|
||||
/**
|
||||
* 补偿钉钉侧已同意、但 MES 集成未执行的中间节点(审批仍为 RUNNING 时由定时扫描调用)。
|
||||
* <p>
|
||||
* 以集成日志为准判断是否需要补偿,避免 processed_op_count 虚高导致漏补。
|
||||
*
|
||||
* @return 本次补偿触发的节点数
|
||||
*/
|
||||
public int reconcileIntermediateNodes(MesXslApprovalRecord record, JSONObject instance) {
|
||||
if (record == null || instance == null) {
|
||||
return 0;
|
||||
}
|
||||
if (!ApprovalRecordConstants.STATUS_RUNNING.equals(record.getStatus())) {
|
||||
return 0;
|
||||
}
|
||||
MesXslApprovalFlow flow = loadFlow(record.getFlowId());
|
||||
if (flow == null || oConvertUtils.isEmpty(flow.getFlowConfig())) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
List<NodePair> pairs = instanceStageExtractor.alignMesNodesWithTasks(instance, flow.getFlowConfig());
|
||||
if (pairs.isEmpty()) {
|
||||
log.info("{} 中间节点补偿:无节点对齐 instanceId={} recordId={}",
|
||||
LOG_TAG, record.getExternalInstanceId(), record.getId());
|
||||
return 0;
|
||||
}
|
||||
|
||||
int compensated = 0;
|
||||
for (int nodeIndex = 0; nodeIndex < pairs.size(); nodeIndex++) {
|
||||
NodePair pair = pairs.get(nodeIndex);
|
||||
NodeTaskDecision decision = instanceStageExtractor.evaluateNodeTasks(
|
||||
pair.getTaskList(), instanceStageExtractor.resolveApprovalMethod(pair.getMesNode()));
|
||||
if (!decision.isAgreed()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
String planId = resolveNodeIntegrationPlanId(pair.getMesNode());
|
||||
if (oConvertUtils.isNotEmpty(planId) && hasIntegrationSuccess(record.getId(), planId)) {
|
||||
continue;
|
||||
}
|
||||
if (oConvertUtils.isEmpty(planId)) {
|
||||
approvalGateService.tryMarkNodeProcessed(record.getId(), nodeIndex);
|
||||
continue;
|
||||
}
|
||||
|
||||
JSONObject mesNode = pair.getMesNode();
|
||||
String nodeName = mesNode != null ? mesNode.getString("name") : null;
|
||||
log.info("{} 中间节点补偿:触发集成 instanceId={} recordId={} nodeIndex={} nodeName={} planId={}",
|
||||
LOG_TAG, record.getExternalInstanceId(), record.getId(), nodeIndex, nodeName, planId);
|
||||
|
||||
try {
|
||||
fireCompensatedNodeApproved(record, instance, pair, nodeIndex, decision);
|
||||
compensated++;
|
||||
} catch (Exception e) {
|
||||
log.error("{} 中间节点补偿失败 instanceId={} nodeIndex={} nodeName={}: {}",
|
||||
LOG_TAG, record.getExternalInstanceId(), nodeIndex, nodeName, e.getMessage(), e);
|
||||
}
|
||||
}
|
||||
return compensated;
|
||||
}
|
||||
|
||||
private void fireCompensatedNodeApproved(MesXslApprovalRecord record, JSONObject instance,
|
||||
NodePair pair, int nodeIndex, NodeTaskDecision decision) {
|
||||
JSONObject mesNode = pair.getMesNode();
|
||||
String dtUserId = null;
|
||||
if (decision.getActorUserIds() != null && !decision.getActorUserIds().isEmpty()) {
|
||||
dtUserId = decision.getActorUserIds().get(decision.getActorUserIds().size() - 1);
|
||||
}
|
||||
String token = workflowService.generateTokenByDtUserId(dtUserId);
|
||||
|
||||
JSONObject nodeProps = mesNode != null ? mesNode.getJSONObject("props") : null;
|
||||
String stageKey = nodeProps != null ? nodeProps.getString("stageKey") : null;
|
||||
String actioner = "审批人";
|
||||
StageCompletion completion = instanceStageExtractor.extractActivityCompletion(
|
||||
instance, pair.getActivityId(), mesNode);
|
||||
if (completion != null && oConvertUtils.isNotEmpty(completion.getOperatorBy())) {
|
||||
actioner = completion.getOperatorBy();
|
||||
}
|
||||
|
||||
ApprovalCallbackContext ctx = buildContext(record, "钉钉节点补偿(" + actioner + ")", token)
|
||||
.setOperatorName(actioner)
|
||||
.setOperatorTime(decision.getOperatorTime())
|
||||
.setNodeId(mesNode != null ? mesNode.getString("id") : null)
|
||||
.setNodeName(mesNode != null ? mesNode.getString("name") : null)
|
||||
.setStageKey(stageKey)
|
||||
.setActivityId(pair.getActivityId());
|
||||
|
||||
logCallbackDispatch("fireNodeApproved (中间节点补偿)", ctx);
|
||||
callbackDispatcher.fireNodeApproved(ctx);
|
||||
approvalGateService.tryMarkNodeProcessed(record.getId(), nodeIndex);
|
||||
log.info("{} 中间节点补偿完成 instanceId={} nodeIndex={} nodeName={}",
|
||||
LOG_TAG, record.getExternalInstanceId(), nodeIndex, ctx.getNodeName());
|
||||
}
|
||||
|
||||
private String resolveNodeIntegrationPlanId(JSONObject mesNode) {
|
||||
if (mesNode == null) {
|
||||
return null;
|
||||
}
|
||||
JSONObject props = mesNode.getJSONObject("props");
|
||||
if (props == null) {
|
||||
return null;
|
||||
}
|
||||
JSONObject plans = props.getJSONObject("integrationPlans");
|
||||
if (plans == null) {
|
||||
return null;
|
||||
}
|
||||
return plans.getString("onNodeApprove");
|
||||
}
|
||||
|
||||
private boolean hasIntegrationSuccess(String recordId, String planId) {
|
||||
if (oConvertUtils.isEmpty(recordId) || oConvertUtils.isEmpty(planId)) {
|
||||
return false;
|
||||
}
|
||||
return integrationLogService.lambdaQuery()
|
||||
.eq(MesXslIntegrationLog::getRecordId, recordId)
|
||||
.eq(MesXslIntegrationLog::getPlanId, planId)
|
||||
.eq(MesXslIntegrationLog::getStatus, "success")
|
||||
.count() > 0;
|
||||
}
|
||||
//update-end---author:GHT ---date:20260609 for:【钉钉Stream补偿扫描】RUNNING态补中间节点集成-----------
|
||||
|
||||
// ==================== 内部辅助 ====================
|
||||
|
||||
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,33 +620,102 @@ public class DingBpmsEventProcessor {
|
||||
|
||||
private List<JSONObject> loadApproverNodes(String flowId) {
|
||||
List<JSONObject> result = new ArrayList<>();
|
||||
if (oConvertUtils.isEmpty(flowId)) return result;
|
||||
MesXslApprovalFlow flow = loadFlow(flowId);
|
||||
if (flow == null) return result;
|
||||
try {
|
||||
MesXslApprovalFlow flow = approvalFlowService.getById(flowId);
|
||||
if (flow == null || oConvertUtils.isEmpty(flow.getFlowConfig())) return result;
|
||||
collectApproverNodes(JSONObject.parseObject(flow.getFlowConfig()), result);
|
||||
collectAllApproverNodes(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) {
|
||||
//update-begin---author:GHT ---date:2026-06-08 for:【缺陷修复-D2】loadFlow提取为独立方法,供activityId查找和节点遍历复用-----------
|
||||
private MesXslApprovalFlow loadFlow(String flowId) {
|
||||
if (oConvertUtils.isEmpty(flowId)) {
|
||||
log.info("{} 加载流程跳过:flowId 为空", LOG_TAG);
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
MesXslApprovalFlow flow = approvalFlowService.getById(flowId);
|
||||
if (flow == null || oConvertUtils.isEmpty(flow.getFlowConfig())) {
|
||||
log.info("{} 加载流程跳过:流程不存在或无 flowConfig flowId={}", LOG_TAG, flowId);
|
||||
return null;
|
||||
}
|
||||
return flow;
|
||||
} catch (Exception e) {
|
||||
log.warn("{} 加载流程失败 flowId={}: {}", LOG_TAG, flowId, e.getMessage());
|
||||
return null;
|
||||
}
|
||||
}
|
||||
//update-end---author:GHT ---date:2026-06-08 for:【缺陷修复-D2】loadFlow提取为独立方法,供activityId查找和节点遍历复用-----------
|
||||
|
||||
//update-begin---author:GHT ---date:2026-06-08 for:【缺陷修复-D2】collectAllApproverNodes遍历ALL分支,替代只取conditionNodes[0]的旧方案-----------
|
||||
/**
|
||||
* DFS 遍历流程节点树,收集所有审批节点(包括所有条件分支)。
|
||||
* 旧实现 collectApproverNodes 只取 conditionNodes[0],在条件分支场景下会遗漏其他分支的节点。
|
||||
* 新实现遍历所有分支,以 DFS 顺序返回稳定的节点列表,确保 activityId → nodeIndex 映射正确。
|
||||
*/
|
||||
private void collectAllApproverNodes(JSONObject node, List<JSONObject> out) {
|
||||
if (node == null) return;
|
||||
if ("approver".equals(node.getString("type"))) {
|
||||
out.add(node);
|
||||
}
|
||||
JSONArray branches = node.getJSONArray("conditionNodes");
|
||||
if (branches != null && !branches.isEmpty()) {
|
||||
Object first = branches.get(0);
|
||||
if (first instanceof JSONObject) {
|
||||
collectApproverNodes(((JSONObject) first).getJSONObject("childNode"), out);
|
||||
if (branches != null) {
|
||||
for (int i = 0; i < branches.size(); i++) {
|
||||
Object branch = branches.get(i);
|
||||
if (branch instanceof JSONObject) {
|
||||
collectAllApproverNodes(((JSONObject) branch).getJSONObject("childNode"), out);
|
||||
}
|
||||
}
|
||||
}
|
||||
collectApproverNodes(node.getJSONObject("childNode"), out);
|
||||
collectAllApproverNodes(node.getJSONObject("childNode"), out);
|
||||
}
|
||||
|
||||
/** 找第一条 result=REFUSE 的操作记录 */
|
||||
/**
|
||||
* 通过 activityId 在流程树中查找对应的审批节点(遍历所有分支)。
|
||||
*
|
||||
* @return 找到的节点;找不到返回 null
|
||||
*/
|
||||
private JSONObject findNodeByActivityId(String flowConfig, String activityId) {
|
||||
if (oConvertUtils.isEmpty(flowConfig) || oConvertUtils.isEmpty(activityId)) return null;
|
||||
try {
|
||||
List<JSONObject> allNodes = new ArrayList<>();
|
||||
collectAllApproverNodes(JSONObject.parseObject(flowConfig), allNodes);
|
||||
for (JSONObject n : allNodes) {
|
||||
if (activityId.equals(n.getString("id"))) {
|
||||
return n;
|
||||
}
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.warn("{} findNodeByActivityId 失败 activityId={}: {}", LOG_TAG, activityId, e.getMessage());
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取节点在 DFS 全分支遍历顺序中的稳定索引(用于 tryMarkNodeProcessed 幂等键)。
|
||||
*
|
||||
* @return nodeIndex(≥0);找不到返回 -1
|
||||
*/
|
||||
private int getNodeIndexByActivityId(String flowConfig, String activityId) {
|
||||
if (oConvertUtils.isEmpty(flowConfig) || oConvertUtils.isEmpty(activityId)) return -1;
|
||||
try {
|
||||
List<JSONObject> allNodes = new ArrayList<>();
|
||||
collectAllApproverNodes(JSONObject.parseObject(flowConfig), allNodes);
|
||||
for (int i = 0; i < allNodes.size(); i++) {
|
||||
if (activityId.equals(allNodes.get(i).getString("id"))) {
|
||||
return i;
|
||||
}
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.warn("{} getNodeIndexByActivityId 失败 activityId={}: {}", LOG_TAG, activityId, e.getMessage());
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
//update-end---author:GHT ---date:2026-06-08 for:【缺陷修复-D2】collectAllApproverNodes遍历ALL分支,替代只取conditionNodes[0]的旧方案-----------
|
||||
|
||||
private JSONObject findRefuseOp(List<JSONObject> taskOps) {
|
||||
for (JSONObject op : taskOps) {
|
||||
String r = op.getString("result");
|
||||
@@ -308,16 +726,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,15 +739,37 @@ 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 ApprovalCallbackContext buildContext(MesXslApprovalRecord record, String comment) {
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
//update-begin---author:GHT ---date:2026-06-08 for:【缺陷修复-D1】buildContext增加token参数,注入真实审批人JWT Token-----------
|
||||
private ApprovalCallbackContext buildContext(MesXslApprovalRecord record, String comment, String token) {
|
||||
return new ApprovalCallbackContext()
|
||||
.setInstanceId(record.getId())
|
||||
.setFlowId(record.getFlowId())
|
||||
@@ -347,6 +781,58 @@ public class DingBpmsEventProcessor {
|
||||
.setApplyUser(record.getApplyUser())
|
||||
.setComment(comment)
|
||||
.setOperatorUsername("dingtalk")
|
||||
.setOperatorName("钉钉审批");
|
||||
.setOperatorName("钉钉审批")
|
||||
.setToken(token);
|
||||
}
|
||||
//update-end---author:GHT ---date:2026-06-08 for:【缺陷修复-D1】buildContext增加token参数,注入真实审批人JWT Token-----------
|
||||
|
||||
//update-begin---author:GHT ---date:20260608 for:【钉钉Stream回调】解析审批人钉钉userId(多字段兼容)-----------
|
||||
/**
|
||||
* Stream bpms_task_change 事件审批人字段:新版用 staffId/actualActionerId,旧版可能为 actionerUserId。
|
||||
*/
|
||||
private String resolveActionerDtUserId(JSONObject data) {
|
||||
if (data == null) {
|
||||
return null;
|
||||
}
|
||||
String userId = data.getString("actionerUserId");
|
||||
if (oConvertUtils.isEmpty(userId)) {
|
||||
userId = data.getString("staffId");
|
||||
}
|
||||
if (oConvertUtils.isEmpty(userId)) {
|
||||
userId = data.getString("actualActionerId");
|
||||
}
|
||||
return userId;
|
||||
}
|
||||
//update-end---author:GHT ---date:20260608 for:【钉钉Stream回调】解析审批人钉钉userId(多字段兼容)-----------
|
||||
|
||||
//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】钉钉回调统一日志辅助-----------
|
||||
}
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
package org.jeecg.modules.xslmes.dingtalk.stream;
|
||||
|
||||
import com.alibaba.fastjson2.JSONObject;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.jeecg.common.util.oConvertUtils;
|
||||
import org.jeecg.modules.xslmes.approval.entity.MesXslApprovalRecord;
|
||||
import org.jeecg.modules.xslmes.dingtalk.callback.entity.MesXslDingCallbackLog;
|
||||
import org.jeecg.modules.xslmes.dingtalk.callback.service.IMesXslDingCallbackLogService;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.util.Date;
|
||||
|
||||
/**
|
||||
* 钉钉 Stream 回调日志落库辅助类。
|
||||
* <p>
|
||||
* 在 {@link DingTalkStreamSdkRunner} 入站时写入原始推送;在 {@link DingBpmsEventProcessor}
|
||||
* 触发集成/业务回调后更新 processed 及关联业务字段。
|
||||
*/
|
||||
@Slf4j
|
||||
@Component
|
||||
public class DingStreamCallbackLogHelper {
|
||||
|
||||
private static final String LOG_TAG = DingTalkStreamSdkRunner.LOG_TAG;
|
||||
|
||||
@Autowired
|
||||
private IMesXslDingCallbackLogService callbackLogService;
|
||||
|
||||
private final ThreadLocal<ProcessingContext> processingContext = new ThreadLocal<>();
|
||||
|
||||
//update-begin---author:GHT ---date:20260609 for:【钉钉回调日志】Stream入站原始推送落库-----------
|
||||
/**
|
||||
* 记录 Stream 入站事件(所有事件类型均落库)。
|
||||
*
|
||||
* @return 日志主键,供后续更新处理结果
|
||||
*/
|
||||
public String recordInbound(String eventId, String eventType, JSONObject data) {
|
||||
MesXslDingCallbackLog row = new MesXslDingCallbackLog();
|
||||
row.setEventId(eventId);
|
||||
row.setEventType(eventType);
|
||||
row.setReceivedTime(new Date());
|
||||
row.setProcessed(0);
|
||||
if (data != null) {
|
||||
row.setProcessInstanceId(data.getString("processInstanceId"));
|
||||
row.setRawData(data.toJSONString());
|
||||
}
|
||||
try {
|
||||
callbackLogService.save(row);
|
||||
return row.getId();
|
||||
} catch (Exception e) {
|
||||
log.error("{} 回调日志落库失败 eventId={} eventType={}: {}",
|
||||
LOG_TAG, eventId, eventType, e.getMessage(), e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 绑定当前线程正在处理的日志 ID(由 SdkRunner 在调用 Processor 前设置)。
|
||||
*/
|
||||
public void beginProcessing(String logId) {
|
||||
if (oConvertUtils.isEmpty(logId)) {
|
||||
processingContext.remove();
|
||||
return;
|
||||
}
|
||||
processingContext.set(new ProcessingContext(logId));
|
||||
}
|
||||
|
||||
/**
|
||||
* 标记已触发集成/业务回调。
|
||||
*/
|
||||
public void markProcessed(String remark, MesXslApprovalRecord record) {
|
||||
ProcessingContext ctx = processingContext.get();
|
||||
if (ctx == null || ctx.marked) {
|
||||
return;
|
||||
}
|
||||
ctx.marked = true;
|
||||
updateLog(ctx.logId, 1, remark, record);
|
||||
}
|
||||
|
||||
/**
|
||||
* 标记明确跳过(非 BPMS、data 为空等 SdkRunner 层可判定场景)。
|
||||
*/
|
||||
public void markSkipped(String remark) {
|
||||
ProcessingContext ctx = processingContext.get();
|
||||
if (ctx == null) {
|
||||
return;
|
||||
}
|
||||
if (!ctx.marked) {
|
||||
ctx.marked = true;
|
||||
updateLog(ctx.logId, 0, remark, null);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理结束:若 Processor 未显式标记,则记为「已接收但未触发集成处理」。
|
||||
*/
|
||||
public void finishProcessing() {
|
||||
ProcessingContext ctx = processingContext.get();
|
||||
if (ctx != null && !ctx.marked) {
|
||||
updateLog(ctx.logId, 0, "已接收但未触发集成处理", null);
|
||||
}
|
||||
processingContext.remove();
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理异常时更新备注。
|
||||
*/
|
||||
public void markError(String remark) {
|
||||
ProcessingContext ctx = processingContext.get();
|
||||
if (ctx != null) {
|
||||
updateLog(ctx.logId, 0, remark, null);
|
||||
}
|
||||
processingContext.remove();
|
||||
}
|
||||
|
||||
private void updateLog(String logId, int processed, String remark, MesXslApprovalRecord record) {
|
||||
if (oConvertUtils.isEmpty(logId)) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
MesXslDingCallbackLog row = new MesXslDingCallbackLog();
|
||||
row.setId(logId);
|
||||
row.setProcessed(processed);
|
||||
row.setProcessRemark(truncateRemark(remark));
|
||||
if (record != null) {
|
||||
row.setBizTable(record.getBizTable());
|
||||
row.setBizDataId(record.getBizDataId());
|
||||
row.setRecordId(record.getId());
|
||||
}
|
||||
callbackLogService.updateById(row);
|
||||
} catch (Exception e) {
|
||||
log.error("{} 回调日志更新失败 logId={}: {}", LOG_TAG, logId, e.getMessage(), e);
|
||||
}
|
||||
}
|
||||
|
||||
private static String truncateRemark(String remark) {
|
||||
if (remark == null) {
|
||||
return null;
|
||||
}
|
||||
return remark.length() <= 500 ? remark : remark.substring(0, 500);
|
||||
}
|
||||
|
||||
private static final class ProcessingContext {
|
||||
private final String logId;
|
||||
private boolean marked;
|
||||
|
||||
private ProcessingContext(String logId) {
|
||||
this.logId = logId;
|
||||
}
|
||||
}
|
||||
//update-end---author:GHT ---date:20260609 for:【钉钉回调日志】Stream入站原始推送落库-----------
|
||||
}
|
||||
@@ -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;
|
||||
@@ -18,7 +13,11 @@ import org.springframework.stereotype.Component;
|
||||
* 无需注册公网回调地址:应用主动建立长连接,钉钉通过该通道推送事件(如审批结果),
|
||||
* 官方 SDK 内部自动维护重连与心跳。
|
||||
* <p>
|
||||
* 集群模式(默认开启):通过 {@link DingTalkStreamLeaderElection} Redis 选主,
|
||||
* 仅 Leader 节点建连,避免多实例抢消息。
|
||||
* <p>
|
||||
* 启动时机:{@link SmartLifecycle}(phase=MAX-100)确保 Spring 上下文完全就绪后再建连。
|
||||
* SDK 实际启动委托给 {@link DingTalkStreamSdkRunner},避免本类直接引用钉钉 SDK 类型。
|
||||
*
|
||||
* @author GHT
|
||||
* @date 2026-06-04 for:【钉钉Stream回调】基于官方SDK的Stream客户端
|
||||
@@ -27,13 +26,27 @@ import org.springframework.stereotype.Component;
|
||||
@Component
|
||||
public class DingTalkStreamClient implements SmartLifecycle {
|
||||
|
||||
private static final String LOG_TAG = DingTalkStreamSdkRunner.LOG_TAG;
|
||||
private static final String SDK_RUNNER_CLASS = "org.jeecg.modules.xslmes.dingtalk.stream.DingTalkStreamSdkRunner";
|
||||
private static final String SDK_CLIENT_CLASS = "com.dingtalk.open.app.api.OpenDingTalkClient";
|
||||
|
||||
@Autowired
|
||||
private ThirdAppDingtalkServiceImpl dingtalkService;
|
||||
|
||||
@Autowired
|
||||
private DingBpmsEventProcessor bpmsEventProcessor;
|
||||
|
||||
@Autowired
|
||||
private DingStreamCallbackLogHelper callbackLogHelper;
|
||||
|
||||
@Autowired
|
||||
private DingTalkStreamNodeConfigService nodeConfigService;
|
||||
|
||||
@Autowired
|
||||
private DingTalkStreamLeaderElection leaderElection;
|
||||
|
||||
private volatile boolean running = false;
|
||||
private volatile Object streamClientRef;
|
||||
|
||||
// ==================== SmartLifecycle ====================
|
||||
|
||||
@@ -47,7 +60,6 @@ public class DingTalkStreamClient implements SmartLifecycle {
|
||||
@Override
|
||||
public void start() {
|
||||
running = true;
|
||||
// 在后台线程初始化,避免阻塞 Spring 上下文刷新
|
||||
Thread t = new Thread(this::initSdkClient, "ding-stream");
|
||||
t.setDaemon(true);
|
||||
t.start();
|
||||
@@ -56,8 +68,11 @@ public class DingTalkStreamClient implements SmartLifecycle {
|
||||
@Override
|
||||
public void stop() {
|
||||
running = false;
|
||||
// SDK 内部使用 daemon 线程,JVM 退出时自动终止
|
||||
log.info("[DingStream] 钉钉 Stream 客户端已停止");
|
||||
stopStreamClient();
|
||||
if (nodeConfigService.isClusterMode()) {
|
||||
leaderElection.release();
|
||||
}
|
||||
log.info("{} Stream 客户端已停止", LOG_TAG);
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -66,86 +81,133 @@ 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:20260609 for:【钉钉Stream集群】Redis选主+心跳重连生命周期管理-----
|
||||
private void initSdkClient() {
|
||||
//update-begin---author:GHT ---date:20260609 for:【钉钉Stream开发】非接收节点跳过建连-----------
|
||||
if (!nodeConfigService.isThisNodeReceiver()) {
|
||||
log.info("{} 本节点未启用钉钉 Stream 接收 host={} localIps={},"
|
||||
+ "请在【系统管理-第三方配置-钉钉集成】配置 Stream 接收节点白名单",
|
||||
LOG_TAG, nodeConfigService.resolveLocalHostName(), nodeConfigService.resolveLocalIpAddresses());
|
||||
return;
|
||||
}
|
||||
//update-end---author:GHT ---date:20260609 for:【钉钉Stream开发】非接收节点跳过建连-----------
|
||||
|
||||
String[] creds = resolveCredentials();
|
||||
if (creds == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!nodeConfigService.isClusterMode()) {
|
||||
log.info("{} 单实例模式(cluster-mode=false),本节点直接建立 Stream 连接", LOG_TAG);
|
||||
try {
|
||||
startStreamClient(creds);
|
||||
} catch (ClassNotFoundException e) {
|
||||
log.warn("{} Stream SDK 未在 classpath 中(dingtalk-stream),连接未启动", LOG_TAG);
|
||||
} catch (Exception e) {
|
||||
log.error("{} SDK 启动失败: {}", LOG_TAG, e.getMessage(), e);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (!leaderElection.isRedisAvailable()) {
|
||||
log.error("{} 集群模式已开启但 Redis 不可用,Stream 未启动。"
|
||||
+ "请检查 Redis 连接,或设置 jeecg.xslmes.dingtalk.stream.cluster-mode=false", LOG_TAG);
|
||||
return;
|
||||
}
|
||||
|
||||
log.info("{} 集群模式已开启,通过 Redis 选主建立 Stream 连接 instanceId={}",
|
||||
LOG_TAG, leaderElection.instanceId());
|
||||
|
||||
boolean streamActive = false;
|
||||
while (running) {
|
||||
try {
|
||||
if (streamActive) {
|
||||
if (leaderElection.renew()) {
|
||||
sleepQuietly(nodeConfigService.getLeaderRenewIntervalMs());
|
||||
continue;
|
||||
}
|
||||
log.warn("{} Leader 锁续期失败,断开 Stream 并降级为 Follower instanceId={} currentLeader={}",
|
||||
LOG_TAG, leaderElection.instanceId(), leaderElection.currentHolder());
|
||||
stopStreamClient();
|
||||
streamActive = false;
|
||||
leaderElection.release();
|
||||
sleepQuietly(nodeConfigService.getFollowerRetryIntervalMs());
|
||||
continue;
|
||||
}
|
||||
|
||||
if (leaderElection.tryAcquire()) {
|
||||
startStreamClient(creds);
|
||||
streamActive = true;
|
||||
sleepQuietly(nodeConfigService.getLeaderRenewIntervalMs());
|
||||
} else {
|
||||
log.debug("{} 本节点为 Follower,等待 Leader 释放锁 instanceId={} currentLeader={}",
|
||||
LOG_TAG, leaderElection.instanceId(), leaderElection.currentHolder());
|
||||
sleepQuietly(nodeConfigService.getFollowerRetryIntervalMs());
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.error("{} Stream 集群生命周期异常: {}", LOG_TAG, e.getMessage(), e);
|
||||
stopStreamClient();
|
||||
streamActive = false;
|
||||
leaderElection.release();
|
||||
sleepQuietly(nodeConfigService.getFollowerRetryIntervalMs());
|
||||
}
|
||||
}
|
||||
|
||||
if (streamActive) {
|
||||
stopStreamClient();
|
||||
leaderElection.release();
|
||||
}
|
||||
}
|
||||
|
||||
private String[] resolveCredentials() {
|
||||
try {
|
||||
String[] creds = dingtalkService.getDingAppCredentials();
|
||||
if (creds == null || oConvertUtils.isEmpty(creds[0]) || oConvertUtils.isEmpty(creds[1])) {
|
||||
log.warn("[DingStream] 钉钉 AppKey/AppSecret 未配置,Stream 连接未启动。"
|
||||
+ "请在【系统配置-第三方应用】中完成钉钉应用配置后重启服务。");
|
||||
return;
|
||||
log.warn("{} AppKey/AppSecret 未配置,Stream 连接未启动。"
|
||||
+ "请在【系统配置-第三方应用】中完成钉钉应用配置后重启服务。", LOG_TAG);
|
||||
return null;
|
||||
}
|
||||
|
||||
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 客户端已启动,等待审批事件推送");
|
||||
|
||||
} catch (Exception e) {
|
||||
log.error("[DingStream] SDK 启动失败,请检查钉钉配置: {}", 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));
|
||||
return creds;
|
||||
} catch (Exception e) {
|
||||
log.error("{} 读取钉钉凭证失败: {}", LOG_TAG, e.getMessage(), e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
//update-end---author:GHT ---date:20260604 for:【钉钉Stream回调】处理事件并回写审批台账-----
|
||||
|
||||
private void startStreamClient(String[] creds) throws Exception {
|
||||
Class<?> runnerClass = Class.forName(SDK_RUNNER_CLASS);
|
||||
Object client = runnerClass
|
||||
.getMethod("start", String.class, String.class, DingBpmsEventProcessor.class,
|
||||
DingStreamCallbackLogHelper.class)
|
||||
.invoke(null, creds[0], creds[1], bpmsEventProcessor, callbackLogHelper);
|
||||
streamClientRef = client;
|
||||
}
|
||||
|
||||
private void stopStreamClient() {
|
||||
if (streamClientRef == null) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
Class<?> runnerClass = Class.forName(SDK_RUNNER_CLASS);
|
||||
Class<?> clientClass = Class.forName(SDK_CLIENT_CLASS);
|
||||
runnerClass.getMethod("stop", clientClass).invoke(null, streamClientRef);
|
||||
} catch (ClassNotFoundException e) {
|
||||
log.warn("{} Stream SDK 未在 classpath 中,跳过断开", LOG_TAG);
|
||||
} catch (Exception e) {
|
||||
log.warn("{} Stream 断开失败: {}", LOG_TAG, e.getMessage(), e);
|
||||
} finally {
|
||||
streamClientRef = null;
|
||||
}
|
||||
}
|
||||
|
||||
private void sleepQuietly(long ms) {
|
||||
try {
|
||||
Thread.sleep(ms);
|
||||
} catch (InterruptedException e) {
|
||||
Thread.currentThread().interrupt();
|
||||
}
|
||||
}
|
||||
//update-end---author:GHT ---date:20260609 for:【钉钉Stream集群】Redis选主+心跳重连生命周期管理-----
|
||||
}
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
package org.jeecg.modules.xslmes.dingtalk.stream;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.scheduling.annotation.Scheduled;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/**
|
||||
* 钉钉 Stream 连接存活状态监控(定时日志)。
|
||||
*/
|
||||
@Slf4j
|
||||
@Component
|
||||
public class DingTalkStreamHealthMonitor {
|
||||
|
||||
private static final String LOG_TAG = DingTalkStreamSdkRunner.LOG_TAG;
|
||||
|
||||
@Autowired
|
||||
private DingTalkStreamNodeConfigService nodeConfigService;
|
||||
|
||||
@Autowired
|
||||
private DingTalkStreamLeaderElection leaderElection;
|
||||
|
||||
//update-begin---author:GHT ---date:20260609 for:【钉钉Stream监控】定时输出存活与心跳状态-----------
|
||||
@Scheduled(fixedDelayString = "${jeecg.xslmes.dingtalk.stream.health-log-interval-ms:60000}")
|
||||
public void reportHealth() {
|
||||
if (!nodeConfigService.isThisNodeReceiver()) {
|
||||
log.debug("{} Stream存活状态 role=DISABLED host={}(本节点未配置为钉钉回调接收机)",
|
||||
LOG_TAG, nodeConfigService.resolveLocalHostName());
|
||||
return;
|
||||
}
|
||||
DingTalkStreamSdkRunner.ConnectionSnapshot snap = DingTalkStreamSdkRunner.snapshot();
|
||||
long now = System.currentTimeMillis();
|
||||
|
||||
boolean clusterMode = nodeConfigService.isClusterMode();
|
||||
String role;
|
||||
String leaderHolder;
|
||||
if (!clusterMode) {
|
||||
role = "STANDALONE";
|
||||
leaderHolder = leaderElection.instanceId();
|
||||
} else if (leaderElection.isLeader()) {
|
||||
role = "LEADER";
|
||||
leaderHolder = leaderElection.instanceId();
|
||||
} else {
|
||||
role = "FOLLOWER";
|
||||
leaderHolder = leaderElection.currentHolder();
|
||||
}
|
||||
|
||||
Long connectedSec = snap.connectedAtMs() > 0 ? (now - snap.connectedAtMs()) / 1000 : null;
|
||||
Long lastEventAgoSec = snap.lastEventAtMs() > 0 ? (now - snap.lastEventAtMs()) / 1000 : null;
|
||||
|
||||
log.info("{} Stream存活状态 role={} instanceId={} leaderHolder={} streamRunning={} connectedSec={} "
|
||||
+ "lastEventAgoSec={} totalEvents={} reconnectCount={}",
|
||||
LOG_TAG, role, leaderElection.instanceId(), leaderHolder,
|
||||
snap.streamRunning(), connectedSec, lastEventAgoSec,
|
||||
snap.totalEventCount(), snap.reconnectCount());
|
||||
|
||||
if ("LEADER".equals(role) && snap.streamRunning()
|
||||
&& snap.lastEventAtMs() > 0
|
||||
&& (now - snap.lastEventAtMs()) > nodeConfigService.getIdleWarnSeconds() * 1000L) {
|
||||
log.warn("{} Stream长时间无推送(可能业务空闲或连接异常)idleSec={} thresholdSec={}",
|
||||
LOG_TAG, lastEventAgoSec, nodeConfigService.getIdleWarnSeconds());
|
||||
}
|
||||
}
|
||||
//update-end---author:GHT ---date:20260609 for:【钉钉Stream监控】定时输出存活与心跳状态-----------
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
package org.jeecg.modules.xslmes.dingtalk.stream;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.data.redis.core.RedisTemplate;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.lang.management.ManagementFactory;
|
||||
import java.net.InetAddress;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
/**
|
||||
* 钉钉 Stream 集群 Leader 选举(Redis 分布式锁)。
|
||||
* <p>
|
||||
* 同一 AppKey 在多个 JeecgBoot 实例上只能有一个活跃 Stream 连接,
|
||||
* 避免消息被随机节点抢走且处理失败。
|
||||
*/
|
||||
@Slf4j
|
||||
@Component
|
||||
public class DingTalkStreamLeaderElection {
|
||||
|
||||
private static final String LOCK_KEY = "mes:xsl:dingtalk:stream:leader";
|
||||
private static final long LOCK_TTL_SECONDS = 30L;
|
||||
|
||||
private final String instanceId;
|
||||
private final RedisTemplate<String, Object> redisTemplate;
|
||||
|
||||
@Autowired
|
||||
public DingTalkStreamLeaderElection(@Autowired(required = false) RedisTemplate<String, Object> redisTemplate) {
|
||||
this.redisTemplate = redisTemplate;
|
||||
this.instanceId = buildInstanceId();
|
||||
}
|
||||
|
||||
public String instanceId() {
|
||||
return instanceId;
|
||||
}
|
||||
|
||||
public boolean isRedisAvailable() {
|
||||
return redisTemplate != null;
|
||||
}
|
||||
|
||||
//update-begin---author:GHT ---date:20260609 for:【钉钉Stream集群】Redis选主仅单节点建连-----------
|
||||
/**
|
||||
* 尝试成为 Leader。
|
||||
*/
|
||||
public boolean tryAcquire() {
|
||||
if (redisTemplate == null) {
|
||||
return false;
|
||||
}
|
||||
Boolean acquired = redisTemplate.opsForValue()
|
||||
.setIfAbsent(LOCK_KEY, instanceId, LOCK_TTL_SECONDS, TimeUnit.SECONDS);
|
||||
boolean ok = Boolean.TRUE.equals(acquired);
|
||||
if (ok) {
|
||||
log.info("{} 获得 Stream Leader 锁 instanceId={}", DingTalkStreamSdkRunner.LOG_TAG, instanceId);
|
||||
}
|
||||
return ok;
|
||||
}
|
||||
|
||||
/**
|
||||
* 续期 Leader 锁。
|
||||
*/
|
||||
public boolean renew() {
|
||||
if (redisTemplate == null) {
|
||||
return false;
|
||||
}
|
||||
Object current = redisTemplate.opsForValue().get(LOCK_KEY);
|
||||
if (!instanceId.equals(String.valueOf(current))) {
|
||||
return false;
|
||||
}
|
||||
return Boolean.TRUE.equals(redisTemplate.expire(LOCK_KEY, LOCK_TTL_SECONDS, TimeUnit.SECONDS));
|
||||
}
|
||||
|
||||
/**
|
||||
* 释放 Leader 锁(仅当前持有者有效)。
|
||||
*/
|
||||
public void release() {
|
||||
if (redisTemplate == null) {
|
||||
return;
|
||||
}
|
||||
Object current = redisTemplate.opsForValue().get(LOCK_KEY);
|
||||
if (instanceId.equals(String.valueOf(current))) {
|
||||
redisTemplate.delete(LOCK_KEY);
|
||||
log.info("{} 已释放 Stream Leader 锁 instanceId={}", DingTalkStreamSdkRunner.LOG_TAG, instanceId);
|
||||
}
|
||||
}
|
||||
|
||||
public String currentHolder() {
|
||||
if (redisTemplate == null) {
|
||||
return null;
|
||||
}
|
||||
Object holder = redisTemplate.opsForValue().get(LOCK_KEY);
|
||||
return holder != null ? String.valueOf(holder) : null;
|
||||
}
|
||||
|
||||
public boolean isLeader() {
|
||||
return instanceId.equals(currentHolder());
|
||||
}
|
||||
//update-end---author:GHT ---date:20260609 for:【钉钉Stream集群】Redis选主仅单节点建连-----------
|
||||
|
||||
private static String buildInstanceId() {
|
||||
String host = "unknown-host";
|
||||
try {
|
||||
host = InetAddress.getLocalHost().getHostName();
|
||||
} catch (Exception ignored) {
|
||||
// 使用默认 host 标识
|
||||
}
|
||||
String pid = ManagementFactory.getRuntimeMXBean().getName();
|
||||
return host + ":" + pid + "@" + UUID.randomUUID().toString().substring(0, 8);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
package org.jeecg.modules.xslmes.dingtalk.stream;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.jeecg.common.util.oConvertUtils;
|
||||
import org.jeecg.modules.system.entity.SysThirdAppConfig;
|
||||
import org.jeecg.modules.system.service.impl.ThirdAppDingtalkServiceImpl;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.scheduling.annotation.Scheduled;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* 钉钉 Stream 接收节点运行时配置(DB 优先,YAML 兜底)。
|
||||
* <p>
|
||||
* 配置来源:第三方应用「钉钉集成」页面保存的 {@link SysThirdAppConfig}(stream_enabled=1 的记录)。
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
public class DingTalkStreamNodeConfigService {
|
||||
|
||||
private static final String LOG_TAG = DingTalkStreamSdkRunner.LOG_TAG;
|
||||
|
||||
@Autowired
|
||||
private ThirdAppDingtalkServiceImpl dingtalkService;
|
||||
|
||||
@Autowired
|
||||
private DingTalkStreamProperties yamlProperties;
|
||||
|
||||
private volatile ResolvedConfig cachedConfig;
|
||||
private volatile long cachedAtMs;
|
||||
|
||||
//update-begin---author:GHT ---date:20260609 for:【钉钉Stream开发】DB配置优先于YAML控制接收节点-----------
|
||||
public boolean isThisNodeReceiver() {
|
||||
ResolvedConfig config = resolveConfig();
|
||||
if (!config.receiverEnabled) {
|
||||
return false;
|
||||
}
|
||||
boolean hasHostRule = !config.designatedHosts.isEmpty();
|
||||
boolean hasIpRule = !config.designatedIps.isEmpty();
|
||||
if (!hasHostRule && !hasIpRule) {
|
||||
return true;
|
||||
}
|
||||
if (hasHostRule && matchesHost(config.designatedHosts)) {
|
||||
return true;
|
||||
}
|
||||
return hasIpRule && matchesIp(config.designatedIps);
|
||||
}
|
||||
|
||||
public boolean isClusterMode() {
|
||||
return resolveConfig().clusterMode;
|
||||
}
|
||||
|
||||
public long getLeaderRenewIntervalMs() {
|
||||
return yamlProperties.getLeaderRenewIntervalMs();
|
||||
}
|
||||
|
||||
public long getFollowerRetryIntervalMs() {
|
||||
return yamlProperties.getFollowerRetryIntervalMs();
|
||||
}
|
||||
|
||||
public long getIdleWarnSeconds() {
|
||||
return yamlProperties.getIdleWarnSeconds();
|
||||
}
|
||||
|
||||
public String resolveLocalHostName() {
|
||||
return yamlProperties.resolveLocalHostName();
|
||||
}
|
||||
|
||||
public List<String> resolveLocalIpAddresses() {
|
||||
return yamlProperties.resolveLocalIpAddresses();
|
||||
}
|
||||
|
||||
/** 配置变更后由定时任务自动刷新;保存第三方配置后最多 30 秒内生效 */
|
||||
@Scheduled(fixedDelay = 30_000L)
|
||||
public void refreshCache() {
|
||||
cachedConfig = null;
|
||||
}
|
||||
|
||||
private ResolvedConfig resolveConfig() {
|
||||
long now = System.currentTimeMillis();
|
||||
if (cachedConfig != null && now - cachedAtMs < 25_000L) {
|
||||
return cachedConfig;
|
||||
}
|
||||
synchronized (this) {
|
||||
if (cachedConfig != null && now - cachedAtMs < 25_000L) {
|
||||
return cachedConfig;
|
||||
}
|
||||
cachedConfig = loadFromDbAndYaml();
|
||||
cachedAtMs = System.currentTimeMillis();
|
||||
return cachedConfig;
|
||||
}
|
||||
}
|
||||
|
||||
private ResolvedConfig loadFromDbAndYaml() {
|
||||
SysThirdAppConfig db = dingtalkService.getStreamMasterConfig();
|
||||
boolean receiverEnabled = yamlProperties.isReceiverEnabled();
|
||||
List<String> ips = new ArrayList<>(yamlProperties.getDesignatedIps());
|
||||
List<String> hosts = new ArrayList<>(yamlProperties.getDesignatedHosts());
|
||||
boolean clusterMode = yamlProperties.isClusterMode();
|
||||
String source = "YAML";
|
||||
|
||||
if (db != null) {
|
||||
if (db.getStreamReceiverEnabled() != null) {
|
||||
receiverEnabled = db.getStreamReceiverEnabled() == 1;
|
||||
}
|
||||
// null 表示未在页面配置,沿用 YAML;空字符串表示明确清空白名单
|
||||
if (db.getStreamDesignatedIps() != null) {
|
||||
ips = splitCsv(db.getStreamDesignatedIps());
|
||||
}
|
||||
if (db.getStreamDesignatedHosts() != null) {
|
||||
hosts = splitCsv(db.getStreamDesignatedHosts());
|
||||
}
|
||||
if (db.getStreamClusterMode() != null) {
|
||||
clusterMode = db.getStreamClusterMode() == 1;
|
||||
}
|
||||
source = "DB";
|
||||
}
|
||||
|
||||
log.debug("{} Stream节点配置已加载 source={} receiverEnabled={} ips={} hosts={} clusterMode={}",
|
||||
LOG_TAG, source, receiverEnabled, ips, hosts, clusterMode);
|
||||
return new ResolvedConfig(receiverEnabled, ips, hosts, clusterMode);
|
||||
}
|
||||
|
||||
private List<String> splitCsv(String raw) {
|
||||
if (oConvertUtils.isEmpty(raw)) {
|
||||
return Collections.emptyList();
|
||||
}
|
||||
return Arrays.stream(raw.split("[,;\\s]+"))
|
||||
.map(String::trim)
|
||||
.filter(s -> !s.isEmpty())
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
|
||||
private boolean matchesHost(List<String> allowedHosts) {
|
||||
String host = resolveLocalHostName();
|
||||
for (String allowed : allowedHosts) {
|
||||
if (allowed.equalsIgnoreCase(host)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private boolean matchesIp(List<String> allowedIps) {
|
||||
List<String> localIps = resolveLocalIpAddresses();
|
||||
for (String allowed : allowedIps) {
|
||||
for (String localIp : localIps) {
|
||||
if (allowed.equals(localIp)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 供第三方配置页展示:本机网络信息与当前是否接收 Stream。
|
||||
*/
|
||||
public java.util.Map<String, Object> buildNodeInfoSnapshot() {
|
||||
ResolvedConfig config = resolveConfig();
|
||||
java.util.Map<String, Object> map = new java.util.LinkedHashMap<>();
|
||||
map.put("hostName", resolveLocalHostName());
|
||||
map.put("localIps", resolveLocalIpAddresses());
|
||||
map.put("thisNodeReceiver", isThisNodeReceiver());
|
||||
map.put("receiverEnabled", config.receiverEnabled);
|
||||
map.put("designatedIps", config.designatedIps);
|
||||
map.put("designatedHosts", config.designatedHosts);
|
||||
map.put("clusterMode", config.clusterMode);
|
||||
return map;
|
||||
}
|
||||
|
||||
private static final class ResolvedConfig {
|
||||
private final boolean receiverEnabled;
|
||||
private final List<String> designatedIps;
|
||||
private final List<String> designatedHosts;
|
||||
private final boolean clusterMode;
|
||||
|
||||
private ResolvedConfig(boolean receiverEnabled, List<String> designatedIps,
|
||||
List<String> designatedHosts, boolean clusterMode) {
|
||||
this.receiverEnabled = receiverEnabled;
|
||||
this.designatedIps = designatedIps != null ? designatedIps : Collections.emptyList();
|
||||
this.designatedHosts = designatedHosts != null ? designatedHosts : Collections.emptyList();
|
||||
this.clusterMode = clusterMode;
|
||||
}
|
||||
}
|
||||
//update-end---author:GHT ---date:20260609 for:【钉钉Stream开发】DB配置优先于YAML控制接收节点-----------
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
package org.jeecg.modules.xslmes.dingtalk.stream;
|
||||
|
||||
import lombok.Data;
|
||||
import org.jeecg.common.util.oConvertUtils;
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.net.InetAddress;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 钉钉 Stream 集群与监控配置。
|
||||
*/
|
||||
@Data
|
||||
@Component
|
||||
@ConfigurationProperties(prefix = "jeecg.xslmes.dingtalk.stream")
|
||||
public class DingTalkStreamProperties {
|
||||
|
||||
/**
|
||||
* 本节点是否参与钉钉 Stream 接收(含补偿扫描)。
|
||||
* 共享 dev 环境建议默认 false,开发者在本机 application-dev-local.yml 中设为 true。
|
||||
*/
|
||||
private boolean receiverEnabled = true;
|
||||
|
||||
/**
|
||||
* 允许接收钉钉回调的主机名白名单(不区分大小写,可选)。
|
||||
* 与 designated-ips 二选一或同时配置:满足任一即视为本机接收节点。
|
||||
*/
|
||||
private List<String> designatedHosts = new ArrayList<>();
|
||||
|
||||
/**
|
||||
* 允许接收钉钉回调的本机 IP 白名单(推荐,比主机名更稳定)。
|
||||
* 匹配本机网卡 IPv4/IPv6 地址,与 designated-hosts 满足其一即可。
|
||||
*/
|
||||
private List<String> designatedIps = new ArrayList<>();
|
||||
|
||||
/**
|
||||
* 集群模式:true 时通过 Redis 选主,仅 Leader 节点建立 Stream 长连接。
|
||||
* 多实例生产环境务必保持 true;单实例可设为 false 简化部署。
|
||||
*/
|
||||
private boolean clusterMode = true;
|
||||
|
||||
/** Leader 锁续期间隔(毫秒) */
|
||||
private long leaderRenewIntervalMs = 10_000L;
|
||||
|
||||
/** Follower 抢主重试间隔(毫秒) */
|
||||
private long followerRetryIntervalMs = 15_000L;
|
||||
|
||||
/** 存活状态日志输出间隔(毫秒) */
|
||||
private long healthLogIntervalMs = 60_000L;
|
||||
|
||||
/** 无事件空闲告警阈值(秒),Leader 且连接中超过该时间无推送则 warn */
|
||||
private long idleWarnSeconds = 1800L;
|
||||
|
||||
//update-begin---author:GHT ---date:20260609 for:【钉钉Stream开发】本机白名单仅指定电脑接收回调-----------
|
||||
/**
|
||||
* 当前节点是否应接收钉钉 Stream 推送并执行补偿扫描。
|
||||
*/
|
||||
public boolean isThisNodeReceiver() {
|
||||
if (!receiverEnabled) {
|
||||
return false;
|
||||
}
|
||||
boolean hasHostRule = designatedHosts != null && !designatedHosts.isEmpty();
|
||||
boolean hasIpRule = designatedIps != null && !designatedIps.isEmpty();
|
||||
if (!hasHostRule && !hasIpRule) {
|
||||
return true;
|
||||
}
|
||||
if (hasHostRule && matchesDesignatedHost()) {
|
||||
return true;
|
||||
}
|
||||
return hasIpRule && matchesDesignatedIp();
|
||||
}
|
||||
|
||||
private boolean matchesDesignatedHost() {
|
||||
String host = resolveLocalHostName();
|
||||
for (String allowed : designatedHosts) {
|
||||
if (oConvertUtils.isNotEmpty(allowed) && allowed.equalsIgnoreCase(host)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private boolean matchesDesignatedIp() {
|
||||
List<String> localIps = resolveLocalIpAddresses();
|
||||
for (String allowed : designatedIps) {
|
||||
if (oConvertUtils.isEmpty(allowed)) {
|
||||
continue;
|
||||
}
|
||||
for (String localIp : localIps) {
|
||||
if (allowed.equals(localIp)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
public String resolveLocalHostName() {
|
||||
try {
|
||||
return InetAddress.getLocalHost().getHostName();
|
||||
} catch (Exception e) {
|
||||
return "unknown-host";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 采集本机所有网卡 IP(含 127.0.0.1),供白名单匹配与启动日志展示。
|
||||
*/
|
||||
public List<String> resolveLocalIpAddresses() {
|
||||
List<String> ips = new ArrayList<>();
|
||||
try {
|
||||
java.util.Enumeration<java.net.NetworkInterface> interfaces = java.net.NetworkInterface.getNetworkInterfaces();
|
||||
while (interfaces != null && interfaces.hasMoreElements()) {
|
||||
java.net.NetworkInterface ni = interfaces.nextElement();
|
||||
java.util.Enumeration<InetAddress> addresses = ni.getInetAddresses();
|
||||
while (addresses.hasMoreElements()) {
|
||||
String ip = addresses.nextElement().getHostAddress();
|
||||
if (oConvertUtils.isNotEmpty(ip) && !ips.contains(ip)) {
|
||||
ips.add(ip);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (Exception ignored) {
|
||||
// 回退到 LocalHost
|
||||
}
|
||||
try {
|
||||
String localIp = InetAddress.getLocalHost().getHostAddress();
|
||||
if (oConvertUtils.isNotEmpty(localIp) && !ips.contains(localIp)) {
|
||||
ips.add(localIp);
|
||||
}
|
||||
} catch (Exception ignored) {
|
||||
// 忽略
|
||||
}
|
||||
return ips;
|
||||
}
|
||||
//update-end---author:GHT ---date:20260609 for:【钉钉Stream开发】本机白名单仅指定电脑接收回调-----------
|
||||
}
|
||||
@@ -0,0 +1,242 @@
|
||||
package org.jeecg.modules.xslmes.dingtalk.stream;
|
||||
|
||||
|
||||
|
||||
import com.dingtalk.open.app.api.GenericEventListener;
|
||||
|
||||
import com.dingtalk.open.app.api.KeepAliveOption;
|
||||
|
||||
import com.dingtalk.open.app.api.OpenDingTalkClient;
|
||||
|
||||
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 static volatile OpenDingTalkClient activeClient;
|
||||
|
||||
private static volatile boolean streamRunning;
|
||||
|
||||
private static volatile long connectedAtMs;
|
||||
|
||||
private static volatile long lastEventAtMs;
|
||||
|
||||
private static volatile int totalEventCount;
|
||||
|
||||
private static volatile int reconnectCount;
|
||||
|
||||
|
||||
|
||||
private DingTalkStreamSdkRunner() {
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
//update-begin---author:GHT ---date:20260609 for:【钉钉Stream监控】连接状态快照供存活日志使用-----------
|
||||
|
||||
public static ConnectionSnapshot snapshot() {
|
||||
|
||||
return new ConnectionSnapshot(streamRunning, connectedAtMs, lastEventAtMs, totalEventCount, reconnectCount);
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
public static final class ConnectionSnapshot {
|
||||
|
||||
private final boolean streamRunning;
|
||||
|
||||
private final long connectedAtMs;
|
||||
|
||||
private final long lastEventAtMs;
|
||||
|
||||
private final int totalEventCount;
|
||||
|
||||
private final int reconnectCount;
|
||||
|
||||
|
||||
|
||||
private ConnectionSnapshot(boolean streamRunning, long connectedAtMs, long lastEventAtMs,
|
||||
|
||||
int totalEventCount, int reconnectCount) {
|
||||
|
||||
this.streamRunning = streamRunning;
|
||||
|
||||
this.connectedAtMs = connectedAtMs;
|
||||
|
||||
this.lastEventAtMs = lastEventAtMs;
|
||||
|
||||
this.totalEventCount = totalEventCount;
|
||||
|
||||
this.reconnectCount = reconnectCount;
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
public boolean streamRunning() {
|
||||
|
||||
return streamRunning;
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
public long connectedAtMs() {
|
||||
|
||||
return connectedAtMs;
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
public long lastEventAtMs() {
|
||||
|
||||
return lastEventAtMs;
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
public int totalEventCount() {
|
||||
|
||||
return totalEventCount;
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
public int reconnectCount() {
|
||||
|
||||
return reconnectCount;
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
//update-end---author:GHT ---date:20260609 for:【钉钉Stream监控】连接状态快照供存活日志使用-----------
|
||||
|
||||
|
||||
|
||||
//update-begin---author:GHT ---date:2026-06-05 for:【钉钉Stream回调】将SDK启动逻辑从Spring Bean中剥离-----
|
||||
|
||||
/**
|
||||
|
||||
* 建立钉钉 Stream 长连接并开始接收事件。
|
||||
|
||||
*
|
||||
|
||||
* @param appKey 钉钉 AppKey
|
||||
|
||||
* @param appSecret 钉钉 AppSecret
|
||||
|
||||
* @param processor 审批事件处理器
|
||||
|
||||
* @param logHelper 回调日志落库辅助(可为 null,仅不写库)
|
||||
|
||||
* @return SDK 客户端实例,供集群 Leader 切换时主动 stop
|
||||
|
||||
*/
|
||||
|
||||
public static OpenDingTalkClient start(String appKey, String appSecret, DingBpmsEventProcessor processor,
|
||||
|
||||
DingStreamCallbackLogHelper logHelper) throws Exception {
|
||||
|
||||
stop(activeClient);
|
||||
|
||||
|
||||
|
||||
boolean isReconnect = reconnectCount > 0 || connectedAtMs > 0;
|
||||
|
||||
if (isReconnect) {
|
||||
|
||||
reconnectCount++;
|
||||
|
||||
log.info("{} Stream 正在重连 AppKey={} reconnectCount={}", LOG_TAG, appKey, reconnectCount);
|
||||
|
||||
} else {
|
||||
|
||||
log.info("{} Stream 正在建连 AppKey={}", LOG_TAG, appKey);
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
OpenDingTalkClient client = OpenDingTalkStreamClientBuilder
|
||||
|
||||
.custom()
|
||||
|
||||
.credential(new AuthClientCredential(appKey, appSecret))
|
||||
|
||||
// SDK 内部 WebSocket 心跳,默认 60s 空闲探测
|
||||
|
||||
.keepAlive(KeepAliveOption.create().withKeepAliveIdleMill(60_000L))
|
||||
|
||||
.registerAllEventListener(new GenericEventListener() {
|
||||
|
||||
@Override
|
||||
|
||||
public EventAckStatus onEvent(GenericOpenDingTalkEvent event) {
|
||||
|
||||
//update-begin---author:GHT ---date:20260609 for:【钉钉Stream】推送监听日志-----------
|
||||
|
||||
lastEventAtMs = System.currentTimeMillis();
|
||||
|
||||
totalEventCount++;
|
||||
|
||||
log.info("{} 钉钉Stream推送监听 event={}", LOG_TAG, event);
|
||||
|
||||
//update-end---author:GHT ---date:20260609 for:【钉钉Stream】推送监听日志-----------
|
||||
|
||||
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;
|
||||
|
||||
@@ -10,6 +10,7 @@ import org.jeecg.common.util.oConvertUtils;
|
||||
import org.jeecg.modules.system.entity.SysUser;
|
||||
import org.jeecg.modules.system.service.ISysUserService;
|
||||
import org.jeecg.modules.system.service.impl.ThirdAppDingtalkServiceImpl;
|
||||
import org.jeecg.modules.xslmes.approval.integration.engine.ApprovalInstanceStageExtractor;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.stereotype.Service;
|
||||
@@ -34,9 +35,14 @@ 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";
|
||||
|
||||
private static final String PROCESS_FORECAST_URL =
|
||||
"https://api.dingtalk.com/v1.0/workflow/processes/forecast";
|
||||
|
||||
@Autowired
|
||||
private ThirdAppDingtalkServiceImpl dingtalkService;
|
||||
|
||||
@@ -49,6 +55,9 @@ public class DingTalkWorkflowService {
|
||||
@Autowired
|
||||
private RedisUtil redisUtil;
|
||||
|
||||
@Autowired
|
||||
private ApprovalInstanceStageExtractor instanceStageExtractor;
|
||||
|
||||
// ==================== 审批实例详情 ====================
|
||||
|
||||
//update-begin---author:GHT ---date:20260604 for:【钉钉Stream回调】拉取钉钉审批实例详情-----
|
||||
@@ -60,12 +69,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,18 +94,122 @@ 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回调】拉取钉钉审批实例详情-----
|
||||
|
||||
//update-begin---author:GHT ---date:20260608 for:【审批注册中心】查看钉钉审批实例原始JSON-----------
|
||||
/**
|
||||
* 调用 GET /v1.0/workflow/processInstances 获取接口原始响应(含 result 或错误码)。
|
||||
*
|
||||
* @param processInstanceId 钉钉审批实例 ID
|
||||
* @return 钉钉接口完整 JSON 响应体,请求失败返回 null
|
||||
*/
|
||||
public JSONObject getProcessInstanceRaw(String processInstanceId) {
|
||||
if (oConvertUtils.isEmpty(processInstanceId)) {
|
||||
log.info("{} 拉取审批实例原始响应跳过:processInstanceId 为空", LOG_TAG);
|
||||
return null;
|
||||
}
|
||||
log.info("{} 开始拉取审批实例原始响应 instanceId={}", LOG_TAG, processInstanceId);
|
||||
long startMs = System.currentTimeMillis();
|
||||
String accessToken = dingtalkService.getAccessTokenForBackground();
|
||||
if (oConvertUtils.isEmpty(accessToken)) {
|
||||
log.warn("{} AccessToken 获取失败,无法查询审批实例原始响应 instanceId={}", LOG_TAG, processInstanceId);
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
HttpClient client = HttpClient.newBuilder()
|
||||
.connectTimeout(Duration.ofSeconds(10))
|
||||
.build();
|
||||
HttpRequest req = HttpRequest.newBuilder()
|
||||
.uri(URI.create(PROCESS_INSTANCE_URL + "?processInstanceId=" + processInstanceId))
|
||||
.header("x-acs-dingtalk-access-token", accessToken)
|
||||
.GET()
|
||||
.timeout(Duration.ofSeconds(10))
|
||||
.build();
|
||||
String body = client.send(req, HttpResponse.BodyHandlers.ofString()).body();
|
||||
JSONObject resp = JSONObject.parseObject(body);
|
||||
log.info("{} 拉取审批实例原始响应完成 instanceId={} costMs={}", LOG_TAG, processInstanceId,
|
||||
System.currentTimeMillis() - startMs);
|
||||
return resp;
|
||||
} catch (Exception e) {
|
||||
log.error("{} 调用钉钉审批实例详情接口异常(原始响应) instanceId={} costMs={}: {}",
|
||||
LOG_TAG, processInstanceId, System.currentTimeMillis() - startMs, e.getMessage(), e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
//update-end---author:GHT ---date:20260604 for:【钉钉Stream回调】拉取钉钉审批实例详情-----
|
||||
//update-end---author:GHT ---date:20260608 for:【审批注册中心】查看钉钉审批实例原始JSON-----------
|
||||
|
||||
//update-begin---author:GHT ---date:20260608 for:【审批注册中心】调用processForecast获取审批节点-----------
|
||||
/**
|
||||
* 调用 POST /v1.0/workflow/processes/forecast 预测审批节点。
|
||||
*
|
||||
* @param accessToken 钉钉 accessToken
|
||||
* @param requestBody 含 processCode、userId、deptId、formComponentValues
|
||||
* @return result 节点(含 workflowActivityRules),失败返回 null
|
||||
*/
|
||||
public JSONObject processForecast(String accessToken, JSONObject requestBody) {
|
||||
if (oConvertUtils.isEmpty(accessToken) || requestBody == null || requestBody.isEmpty()) {
|
||||
log.warn("{} processForecast 跳过:accessToken 或请求体为空", LOG_TAG);
|
||||
return null;
|
||||
}
|
||||
long startMs = System.currentTimeMillis();
|
||||
try {
|
||||
HttpClient client = HttpClient.newBuilder()
|
||||
.connectTimeout(Duration.ofSeconds(10))
|
||||
.build();
|
||||
HttpRequest req = HttpRequest.newBuilder()
|
||||
.uri(URI.create(PROCESS_FORECAST_URL))
|
||||
.header("x-acs-dingtalk-access-token", accessToken)
|
||||
.header("Content-Type", "application/json")
|
||||
.POST(HttpRequest.BodyPublishers.ofString(requestBody.toJSONString()))
|
||||
.timeout(Duration.ofSeconds(15))
|
||||
.build();
|
||||
String body = client.send(req, HttpResponse.BodyHandlers.ofString()).body();
|
||||
JSONObject resp = JSONObject.parseObject(body);
|
||||
if (resp.containsKey("code")) {
|
||||
log.warn("{} processForecast 失败 code={} msg={} costMs={}",
|
||||
LOG_TAG, resp.getString("code"), resp.getString("message"),
|
||||
System.currentTimeMillis() - startMs);
|
||||
return null;
|
||||
}
|
||||
JSONObject result = resp.getJSONObject("result");
|
||||
JSONArray rules = result == null ? null : result.getJSONArray("workflowActivityRules");
|
||||
int ruleCount = rules == null ? 0 : rules.size();
|
||||
log.info("{} processForecast 成功 processCode={} rules={} costMs={}",
|
||||
LOG_TAG, requestBody.getString("processCode"), ruleCount,
|
||||
System.currentTimeMillis() - startMs);
|
||||
return result;
|
||||
} catch (Exception e) {
|
||||
log.error("{} processForecast 异常 processCode={} costMs={}: {}",
|
||||
LOG_TAG, requestBody.getString("processCode"),
|
||||
System.currentTimeMillis() - startMs, e.getMessage(), e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
//update-end---author:GHT ---date:20260608 for:【审批注册中心】调用processForecast获取审批节点-----------
|
||||
|
||||
// ==================== operationRecords 解析 ====================
|
||||
|
||||
@@ -106,10 +223,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 +241,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提取节点操作序列-----
|
||||
@@ -131,7 +255,10 @@ public class DingTalkWorkflowService {
|
||||
/**
|
||||
* 将钉钉 userId 映射到 MES 系统用户,并生成该用户的 JWT Token。
|
||||
* <p>
|
||||
* 查询链:sys_third_account(third_user_id=dtUserId) → sys_user_id → sys_user.username/password
|
||||
* 查询链(三级降级):
|
||||
* ① sys_user.ding_user_id = dtUserId(本地字段,无需 JOIN,最快)
|
||||
* ② sys_third_account.third_user_id = dtUserId(第三方账号绑定表)
|
||||
* ③ 均无命中 → admin 兜底
|
||||
* <p>
|
||||
* 这样回调业务接口时,接口内部通过 {@code SecurityUtils.getSubject().getPrincipal()}
|
||||
* 拿到的就是真实审批人,而非 admin,保证 proofread_by/audit_by/approve_by 字段写入正确。
|
||||
@@ -141,41 +268,343 @@ public class DingTalkWorkflowService {
|
||||
*/
|
||||
public String generateTokenByDtUserId(String dtUserId) {
|
||||
if (oConvertUtils.isEmpty(dtUserId)) {
|
||||
log.info("{} 生成操作人Token跳过:dtUserId 为空", LOG_TAG);
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
// ① 钉钉userId → MES sys_user_id
|
||||
//update-begin---author:GHT ---date:2026-06-08 for:【XSLMES-20260608】优先查sys_user.ding_user_id,减少DingTalk API调用-----------
|
||||
// ① 优先通过 sys_user.ding_user_id 直接定位(本地字段,最快)
|
||||
List<String> localIds = jdbcTemplate.queryForList(
|
||||
"SELECT id FROM sys_user WHERE ding_user_id=? AND (del_flag=0 OR del_flag IS NULL) LIMIT 1",
|
||||
String.class, dtUserId);
|
||||
if (!localIds.isEmpty() && oConvertUtils.isNotEmpty(localIds.get(0))) {
|
||||
SysUser localUser = sysUserService.getById(localIds.get(0));
|
||||
if (localUser != null && oConvertUtils.isNotEmpty(localUser.getPassword())) {
|
||||
log.info("{} 生成操作人Token成功(ding_user_id) dtUserId={} mesUsername={}", LOG_TAG, dtUserId, localUser.getUsername());
|
||||
return signAndCache(localUser.getUsername(), localUser.getPassword());
|
||||
}
|
||||
}
|
||||
// ② 降级:查 sys_third_account(第三方账号绑定表)
|
||||
List<String> userIds = jdbcTemplate.queryForList(
|
||||
"SELECT sys_user_id FROM sys_third_account " +
|
||||
"WHERE third_type='dingtalk' AND third_user_id=? AND (del_flag=0 OR del_flag IS NULL) LIMIT 1",
|
||||
String.class, dtUserId);
|
||||
//update-end---author:GHT ---date:2026-06-08 for:【XSLMES-20260608】优先查sys_user.ding_user_id,减少DingTalk API调用-----------
|
||||
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
|
||||
// ③ 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成功(sys_third_account) 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();
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== activityId 辅助方法 ====================
|
||||
|
||||
//update-begin---author:GHT ---date:2026-06-08 for:【缺陷修复-D2/D3/D4】基于activityId精准定位节点,替代completionAt计数方案-----------
|
||||
/**
|
||||
* 从实例 operationRecords 中找到 staffId 对应的最新一条执行记录的 activityId。
|
||||
* <p>
|
||||
* bpms_task_change 事件本身不携带 activityId,需通过此方法从拉取的实例详情中反查。
|
||||
*
|
||||
* @param instanceResult getProcessInstance 返回的 result 节点
|
||||
* @param staffId bpms_task_change 事件中的 staffId(当前审批人钉钉userId)
|
||||
* @return activityId;找不到时返回 null
|
||||
*/
|
||||
public String resolveActivityIdForEvent(JSONObject instanceResult, String staffId) {
|
||||
if (instanceResult == null || oConvertUtils.isEmpty(staffId)) {
|
||||
log.info("{} resolveActivityIdForEvent 跳过:instanceResult 或 staffId 为空", LOG_TAG);
|
||||
return null;
|
||||
}
|
||||
JSONArray records = instanceResult.getJSONArray("operationRecords");
|
||||
if (records == null || records.isEmpty()) {
|
||||
log.info("{} resolveActivityIdForEvent 跳过:operationRecords 为空", LOG_TAG);
|
||||
return null;
|
||||
}
|
||||
// 取最后一条匹配的执行记录(多次审批同一节点时取最新)
|
||||
String activityId = null;
|
||||
for (int i = 0; i < records.size(); i++) {
|
||||
JSONObject rec = records.getJSONObject(i);
|
||||
if (rec == null) continue;
|
||||
String type = rec.getString("type");
|
||||
if (!"EXECUTE_TASK_NORMAL".equals(type) && !"EXECUTE_TASK_AGENT".equals(type)) continue;
|
||||
if (staffId.equals(rec.getString("userId"))) {
|
||||
String aid = rec.getString("activityId");
|
||||
if (oConvertUtils.isNotEmpty(aid)) {
|
||||
activityId = aid;
|
||||
}
|
||||
}
|
||||
}
|
||||
log.info("{} resolveActivityIdForEvent staffId={} activityId={}", LOG_TAG, staffId, activityId);
|
||||
return activityId;
|
||||
}
|
||||
|
||||
//update-begin---author:GHT ---date:2026-06-08 for:【缺陷修复-D2/D3】DingTalk activityId 与 MES 节点 id 属独立 ID 体系,改用顺序位置映射-----------
|
||||
/**
|
||||
* 确定 activityId 在审批流程中的步骤序号(0-based)。
|
||||
* <p>
|
||||
* DingTalk 为表单审批自动分配的 activityId 与 MES 设计时节点 id 属独立 ID 体系,无法直接匹配。
|
||||
* 改用"activityId 在 operationRecords 中首次出现的顺序位置"作为步骤序号,
|
||||
* 该位置与 MES 流程 DFS 节点顺序一一对应(发起时钉钉步骤 ↔ MES 节点按相同顺序排列)。
|
||||
*
|
||||
* @param instanceResult getProcessInstance 返回的 result 节点
|
||||
* @param activityId 当前步骤的 DingTalk activityId
|
||||
* @return 0-based 步骤序号;找不到时返回 -1
|
||||
*/
|
||||
public int resolveStepIndex(JSONObject instanceResult, String activityId) {
|
||||
if (instanceResult == null || oConvertUtils.isEmpty(activityId)) return -1;
|
||||
JSONArray records = instanceResult.getJSONArray("operationRecords");
|
||||
if (records == null || records.isEmpty()) return -1;
|
||||
List<String> distinctIds = new ArrayList<>();
|
||||
for (int i = 0; i < records.size(); i++) {
|
||||
JSONObject rec = records.getJSONObject(i);
|
||||
if (rec == null) continue;
|
||||
String type = rec.getString("type");
|
||||
if (!"EXECUTE_TASK_NORMAL".equals(type) && !"EXECUTE_TASK_AGENT".equals(type)) continue;
|
||||
String aid = rec.getString("activityId");
|
||||
if (oConvertUtils.isNotEmpty(aid) && !distinctIds.contains(aid)) {
|
||||
distinctIds.add(aid);
|
||||
}
|
||||
}
|
||||
int idx = distinctIds.indexOf(activityId);
|
||||
log.info("{} resolveStepIndex activityId={} distinctSequence={} stepIndex={}", LOG_TAG, activityId, distinctIds, idx);
|
||||
return idx;
|
||||
}
|
||||
//update-end---author:GHT ---date:2026-06-08 for:【缺陷修复-D2/D3】DingTalk activityId 与 MES 节点 id 属独立 ID 体系,改用顺序位置映射-----------
|
||||
|
||||
/**
|
||||
* 检查某节点(activityId)是否已完成:查看 tasks[] 中是否还有该节点的 RUNNING/NEW 任务。
|
||||
* <p>
|
||||
* 用于替代 completionAt 计数边界检测:会签/依次审批时,只要该 activityId 下还有
|
||||
* RUNNING 或 NEW 状态任务,说明节点仍在进行中,不应触发节点回调。
|
||||
*
|
||||
* @param instanceResult getProcessInstance 返回的 result 节点
|
||||
* @param activityId 要检查的节点活动ID
|
||||
* @return true=节点已完成(或找不到该节点的任务);false=节点仍有待处理任务
|
||||
*/
|
||||
public boolean isNodeComplete(JSONObject instanceResult, String activityId) {
|
||||
if (instanceResult == null || oConvertUtils.isEmpty(activityId)) {
|
||||
return true;
|
||||
}
|
||||
JSONArray tasks = instanceResult.getJSONArray("tasks");
|
||||
if (tasks == null || tasks.isEmpty()) {
|
||||
return true;
|
||||
}
|
||||
for (int i = 0; i < tasks.size(); i++) {
|
||||
JSONObject task = tasks.getJSONObject(i);
|
||||
if (task == null) continue;
|
||||
if (!activityId.equals(task.getString("activityId"))) continue;
|
||||
String status = task.getString("status");
|
||||
if ("RUNNING".equals(status) || "NEW".equals(status)) {
|
||||
log.info("{} isNodeComplete=false activityId={} taskId={} status={}",
|
||||
LOG_TAG, activityId, task.getLongValue("taskId"), status);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
//update-begin---author:GHT ---date:20260608 for:【钉钉Stream回调】会签节点完成判定增强(刷新实例+multiMode兜底)-----------
|
||||
/**
|
||||
* @deprecated 使用 {@link #resolveInstanceWhenNodeComplete(String, JSONObject, String, JSONObject, String)}
|
||||
*/
|
||||
@Deprecated
|
||||
public JSONObject resolveInstanceWhenNodeComplete(String processInstanceId,
|
||||
JSONObject instance,
|
||||
String activityId,
|
||||
JSONObject mesNode) {
|
||||
return resolveInstanceWhenNodeComplete(processInstanceId, instance, activityId, mesNode, null);
|
||||
}
|
||||
|
||||
//update-begin---author:GHT ---date:20260608 for:【缺陷修复-会签集成】新增actionerDtUserId参数,修复AND模式API延迟导致最后一个会签人任务仍RUNNING时节点误判未完成-----------
|
||||
/**
|
||||
* 会签/依次审批场景:finish 事件到达时 tasks 可能短暂滞后,刷新实例后再判定;
|
||||
* 仍不满足时用 MES 节点 multiMode 对同 activityId 的 tasks 做完成判定。
|
||||
* <p>
|
||||
* {@code actionerDtUserId}:AND/ONE_BY_ONE 模式下,若仅该用户的任务仍为 RUNNING(API 延迟),
|
||||
* 而其他参与人均已 AGREE,则视节点为已完成,避免竞态漏触发集成方案。
|
||||
*
|
||||
* @param actionerDtUserId 触发本次 bpms_task_change 的审批人钉钉 userId
|
||||
* @return 节点已完成的最新实例快照;未完成返回 null
|
||||
*/
|
||||
public JSONObject resolveInstanceWhenNodeComplete(String processInstanceId,
|
||||
JSONObject instance,
|
||||
String activityId,
|
||||
JSONObject mesNode,
|
||||
String actionerDtUserId) {
|
||||
if (isNodeCompleteForCallback(instance, activityId, mesNode, actionerDtUserId)) {
|
||||
return instance;
|
||||
}
|
||||
JSONObject refreshed = getProcessInstance(processInstanceId);
|
||||
if (refreshed != null && isNodeCompleteForCallback(refreshed, activityId, mesNode, actionerDtUserId)) {
|
||||
log.info("{} 会签节点完成判定:刷新实例后通过 activityId={} instanceId={}",
|
||||
LOG_TAG, activityId, processInstanceId);
|
||||
return refreshed;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
//update-end---author:GHT ---date:20260608 for:【缺陷修复-会签集成】新增actionerDtUserId参数,修复AND模式API延迟导致最后一个会签人任务仍RUNNING时节点误判未完成-----------
|
||||
|
||||
//update-begin---author:GHT ---date:20260608 for:【缺陷修复-会签集成】重构节点完成判定:以tasks全员COMPLETED+AGREE为主判定,去除复杂mode链路-----------
|
||||
/**
|
||||
* 节点是否已完成(可触发集成方案):
|
||||
* <ol>
|
||||
* <li>主判定:同 activityId 下所有非取消任务均为 COMPLETED+AGREE(会签全员通过 / 或签实际通过人已通过)</li>
|
||||
* <li>API滞后兜底:当前审批人任务仍 RUNNING,其余均已 AGREE(AND最后一人事件先于API更新到达)</li>
|
||||
* <li>或签/单人兜底:非AND/ONE_BY_ONE模式时,任意一人AGREE即完成(其他人取消可能短暂延迟)</li>
|
||||
* </ol>
|
||||
*/
|
||||
private boolean isNodeCompleteForCallback(JSONObject instance, String activityId, JSONObject mesNode,
|
||||
String actionerDtUserId) {
|
||||
if (instance == null || oConvertUtils.isEmpty(activityId)) {
|
||||
return false;
|
||||
}
|
||||
List<JSONObject> taskList = filterTasksByActivity(instance, activityId);
|
||||
if (taskList.isEmpty()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 1. 主判定:所有非取消任务均已 COMPLETED+AGREE
|
||||
if (allNonCanceledTasksCompletedAgree(taskList)) {
|
||||
log.info("{} 节点完成判定:全员COMPLETED+AGREE activityId={} taskCount={}",
|
||||
LOG_TAG, activityId, taskList.size());
|
||||
return true;
|
||||
}
|
||||
|
||||
// 2. API滞后兜底:当前审批人任务仍RUNNING,其余均已AGREE
|
||||
if (oConvertUtils.isNotEmpty(actionerDtUserId) && isVirtuallyCompleteByActioner(taskList, actionerDtUserId)) {
|
||||
log.info("{} 节点完成判定:API滞后虚拟完成 activityId={} actionerDtUserId={}",
|
||||
LOG_TAG, activityId, actionerDtUserId);
|
||||
return true;
|
||||
}
|
||||
|
||||
// 3. 或签/单人兜底:任意一人AGREE即可(其他人取消可能短暂延迟,刷新实例后通常能命中主判定)
|
||||
if (mesNode != null && instanceStageExtractor != null) {
|
||||
String method = instanceStageExtractor.resolveApprovalMethod(mesNode);
|
||||
if (!"AND".equalsIgnoreCase(method) && !"ONE_BY_ONE".equalsIgnoreCase(method)) {
|
||||
boolean anyAgree = taskList.stream().anyMatch(t -> t != null
|
||||
&& "COMPLETED".equalsIgnoreCase(t.getString("status"))
|
||||
&& "AGREE".equalsIgnoreCase(t.getString("result")));
|
||||
if (anyAgree) {
|
||||
log.info("{} 节点完成判定:OR/NONE任一AGREE activityId={} method={}",
|
||||
LOG_TAG, activityId, method);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/** 同 activityId 下所有非取消(CANCELED)任务是否均已 COMPLETED+AGREE */
|
||||
private boolean allNonCanceledTasksCompletedAgree(List<JSONObject> taskList) {
|
||||
int activeCount = 0;
|
||||
int completedAgreeCount = 0;
|
||||
for (JSONObject task : taskList) {
|
||||
if (task == null) continue;
|
||||
if ("CANCELED".equalsIgnoreCase(task.getString("status"))) continue;
|
||||
activeCount++;
|
||||
if ("COMPLETED".equalsIgnoreCase(task.getString("status"))
|
||||
&& "AGREE".equalsIgnoreCase(task.getString("result"))) {
|
||||
completedAgreeCount++;
|
||||
}
|
||||
}
|
||||
return activeCount > 0 && completedAgreeCount == activeCount;
|
||||
}
|
||||
|
||||
/**
|
||||
* API滞后竞态补偿:当前审批人任务仍 RUNNING,其余均已 COMPLETED+AGREE,视节点为已完成。
|
||||
* 若存在其他人(非当前审批人)的 RUNNING/NEW 任务,立即返回 false(节点确实未完成)。
|
||||
*/
|
||||
private boolean isVirtuallyCompleteByActioner(List<JSONObject> taskList, String actionerDtUserId) {
|
||||
int activeCount = 0;
|
||||
int agreeCount = 0;
|
||||
int runningByActioner = 0;
|
||||
for (JSONObject task : taskList) {
|
||||
if (task == null) continue;
|
||||
String status = task.getString("status");
|
||||
if ("CANCELED".equalsIgnoreCase(status)) continue;
|
||||
activeCount++;
|
||||
String result = task.getString("result");
|
||||
if ("COMPLETED".equalsIgnoreCase(status) && "AGREE".equalsIgnoreCase(result)) {
|
||||
agreeCount++;
|
||||
} else if ("RUNNING".equalsIgnoreCase(status) || "NEW".equalsIgnoreCase(status)) {
|
||||
if (actionerDtUserId.equals(task.getString("userId"))) {
|
||||
runningByActioner++;
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
return activeCount > 0 && runningByActioner > 0 && (agreeCount + runningByActioner) >= activeCount;
|
||||
}
|
||||
//update-end---author:GHT ---date:20260608 for:【缺陷修复-会签集成】重构节点完成判定:以tasks全员COMPLETED+AGREE为主判定,去除复杂mode链路-----------
|
||||
|
||||
private List<JSONObject> filterTasksByActivity(JSONObject instance, String activityId) {
|
||||
List<JSONObject> result = new ArrayList<>();
|
||||
if (instance == null || oConvertUtils.isEmpty(activityId)) {
|
||||
return result;
|
||||
}
|
||||
JSONArray tasks = instance.getJSONArray("tasks");
|
||||
if (tasks == null) {
|
||||
return result;
|
||||
}
|
||||
for (int i = 0; i < tasks.size(); i++) {
|
||||
JSONObject task = tasks.getJSONObject(i);
|
||||
if (task != null && activityId.equals(task.getString("activityId"))) {
|
||||
result.add(task);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
//update-end---author:GHT ---date:20260608 for:【钉钉Stream回调】会签节点完成判定增强(刷新实例+multiMode兜底)-----------
|
||||
|
||||
/**
|
||||
* 检查实例 operationRecords 中是否存在 REDIRECT_PROCESS(审批退回)记录。
|
||||
* <p>
|
||||
* 存在退回记录时,nodeActivityMap 的 completionAt 计数已失效。
|
||||
* 当前实现在检测到退回后记录警告日志;采用 activityId 方案时退回对节点定位无影响,
|
||||
* 但对幂等性(tryMarkNodeProcessed)有影响:退回后重新审批的节点会被幂等拦截。
|
||||
*
|
||||
* @return true=存在审批退回记录
|
||||
*/
|
||||
public boolean hasRedirectProcess(JSONObject instanceResult) {
|
||||
if (instanceResult == null) return false;
|
||||
JSONArray records = instanceResult.getJSONArray("operationRecords");
|
||||
if (records == null) return false;
|
||||
for (int i = 0; i < records.size(); i++) {
|
||||
JSONObject rec = records.getJSONObject(i);
|
||||
if (rec != null && "REDIRECT_PROCESS".equals(rec.getString("type"))) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
//update-end---author:GHT ---date:2026-06-08 for:【缺陷修复-D2/D3/D4】基于activityId精准定位节点,替代completionAt计数方案-----------
|
||||
|
||||
/** 生成 admin 系统 token,用于钉钉用户未绑定 MES 账号时的兜底 */
|
||||
public String generateAdminToken() {
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package org.jeecg.modules.xslmes.entity;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.IdType;
|
||||
import com.baomidou.mybatisplus.annotation.TableField;
|
||||
import com.baomidou.mybatisplus.annotation.TableId;
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
import com.fasterxml.jackson.annotation.JsonFormat;
|
||||
@@ -11,6 +12,7 @@ import java.util.Date;
|
||||
import lombok.Data;
|
||||
import lombok.EqualsAndHashCode;
|
||||
import lombok.experimental.Accessors;
|
||||
import org.jeecg.common.aspect.annotation.Dict;
|
||||
import org.jeecgframework.poi.excel.annotation.Excel;
|
||||
import org.springframework.format.annotation.DateTimeFormat;
|
||||
|
||||
@@ -148,15 +150,43 @@ public class MesXslMixingSpec implements Serializable {
|
||||
@Schema(description = "变更日期")
|
||||
private Date changeDate;
|
||||
|
||||
//update-begin---author:cursor ---date:20260608 for:【XSLMES-20260608-A01】混炼示方新增状态字段-----------
|
||||
@Excel(name = "状态", width = 12, dicCode = "xslmes_formula_spec_status")
|
||||
@Dict(dicCode = "xslmes_formula_spec_status")
|
||||
@Schema(description = "状态")
|
||||
private String status;
|
||||
//update-end---author:cursor ---date:20260608 for:【XSLMES-20260608-A01】混炼示方新增状态字段-----------
|
||||
|
||||
private String sysOrgCode;
|
||||
|
||||
//update-begin---author:GHT ---date:2026-06-10 for:【混炼示方】queryById补充起草人/变更人姓名-----------
|
||||
@Excel(name = "创建人", width = 12, dictTable = "sys_user", dicText = "realname", dicCode = "username")
|
||||
@Dict(dictTable = "sys_user", dicText = "realname", dicCode = "username")
|
||||
private String createBy;
|
||||
|
||||
/** queryById 等非分页接口补充创建人姓名(DictAspect 仅翻译分页列表) */
|
||||
@TableField(exist = false)
|
||||
@Schema(description = "创建人姓名")
|
||||
private String createBy_dictText;
|
||||
|
||||
@Dict(dictTable = "sys_user", dicText = "realname", dicCode = "username")
|
||||
private String updateBy;
|
||||
|
||||
/** queryById 补充最后修改人姓名 */
|
||||
@TableField(exist = false)
|
||||
@Schema(description = "修改人姓名")
|
||||
private String updateBy_dictText;
|
||||
|
||||
/** queryById 补充起草人姓名(draftBy 存用户名时翻译) */
|
||||
@TableField(exist = false)
|
||||
@Schema(description = "起草人姓名")
|
||||
private String draftBy_dictText;
|
||||
//update-end---author:GHT ---date:2026-06-10 for:【混炼示方】queryById补充起草人/变更人姓名-----------
|
||||
|
||||
@JsonFormat(timezone = "GMT+8", pattern = "yyyy-MM-dd HH:mm:ss")
|
||||
@DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss")
|
||||
private Date createTime;
|
||||
|
||||
private String updateBy;
|
||||
|
||||
@JsonFormat(timezone = "GMT+8", pattern = "yyyy-MM-dd HH:mm:ss")
|
||||
@DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss")
|
||||
private Date updateTime;
|
||||
|
||||
@@ -52,6 +52,14 @@ public class MesXslMixingSpecTcu implements Serializable {
|
||||
@Schema(description = "药品称量位置(字典xslmes_mixing_drug_weigh_pos)")
|
||||
private String drugWeighPos;
|
||||
|
||||
//update-begin---author:GHT ---date:2026-06-10 for:【混炼示方】TCU温度条件新增是否附加/重量-----------
|
||||
@Schema(description = "是否附加(字典yn)")
|
||||
private String isAttach;
|
||||
|
||||
@Schema(description = "附加重量")
|
||||
private BigDecimal attachWeight;
|
||||
//update-end---author:GHT ---date:2026-06-10 for:【混炼示方】TCU温度条件新增是否附加/重量-----------
|
||||
|
||||
@Schema(description = "租户ID")
|
||||
private Integer tenantId;
|
||||
|
||||
|
||||
@@ -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→compile,compile 无上一环节返回 null */
|
||||
|
||||
@@ -29,6 +29,8 @@ import org.jeecg.modules.xslmes.entity.MesXslMixingSpecDownStep;
|
||||
import org.jeecg.modules.xslmes.entity.MesXslMixingSpecMaterial;
|
||||
import org.jeecg.modules.xslmes.entity.MesXslMixingSpecStep;
|
||||
import org.jeecg.modules.xslmes.entity.MesXslMixingSpecTcu;
|
||||
import org.jeecg.modules.system.entity.SysUser;
|
||||
import org.jeecg.modules.system.service.ISysUserService;
|
||||
import org.jeecg.modules.xslmes.entity.MesXslMixerPsCompile;
|
||||
import org.jeecg.modules.xslmes.mapper.MesXslMixingSpecDownStepMapper;
|
||||
import org.jeecg.modules.xslmes.mapper.MesXslMixingSpecMapper;
|
||||
@@ -50,6 +52,8 @@ public class MesXslMixingSpecServiceImpl extends ServiceImpl<MesXslMixingSpecMap
|
||||
|
||||
private static final String TCU_UP = "up_mixer";
|
||||
private static final String TCU_DOWN = "down_mixer";
|
||||
private static final String TCU_ATTACH_YES = "1";
|
||||
private static final String TCU_ATTACH_NO = "0";
|
||||
//update-begin---author:cursor ---date:20260601 for:【XSLMES-20260601-A62】删除混炼示方时同步删除自动生成的B/F段胶料信息-----------
|
||||
private static final Pattern GENERATED_B_RUBBER_SPEC_PATTERN = Pattern.compile("^B\\d", Pattern.CASE_INSENSITIVE);
|
||||
//update-end---author:cursor ---date:20260601 for:【XSLMES-20260601-A62】删除混炼示方时同步删除自动生成的B/F段胶料信息-----------
|
||||
@@ -71,6 +75,9 @@ public class MesXslMixingSpecServiceImpl extends ServiceImpl<MesXslMixingSpecMap
|
||||
@Resource
|
||||
private IMesXslFormulaSpecEditLogService mesXslFormulaSpecEditLogService;
|
||||
|
||||
@Resource
|
||||
private ISysUserService sysUserService;
|
||||
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public void saveMain(
|
||||
@@ -235,6 +242,9 @@ public class MesXslMixingSpecServiceImpl extends ServiceImpl<MesXslMixingSpecMap
|
||||
page.setStepList(queryStepByMainId(id));
|
||||
page.setDownStepList(queryDownStepByMainId(id));
|
||||
page.setTcuList(fillDefaultTcuRows(queryTcuByMainId(id)));
|
||||
//update-begin---author:GHT ---date:2026-06-10 for:【混炼示方】queryById补充起草人/变更人姓名-----------
|
||||
fillUserDisplayText(page);
|
||||
//update-end---author:GHT ---date:2026-06-10 for:【混炼示方】queryById补充起草人/变更人姓名-----------
|
||||
return page;
|
||||
}
|
||||
|
||||
@@ -282,6 +292,33 @@ public class MesXslMixingSpecServiceImpl extends ServiceImpl<MesXslMixingSpecMap
|
||||
return options;
|
||||
}
|
||||
|
||||
//update-begin---author:GHT ---date:2026-06-10 for:【混炼示方】queryById补充起草人/变更人姓名-----------
|
||||
private void fillUserDisplayText(MesXslMixingSpecPage page) {
|
||||
if (page == null) {
|
||||
return;
|
||||
}
|
||||
page.setCreateBy_dictText(resolveUserRealname(page.getCreateBy()));
|
||||
page.setUpdateBy_dictText(resolveUserRealname(page.getUpdateBy()));
|
||||
String draftUsername = StringUtils.isNotBlank(page.getDraftBy()) ? page.getDraftBy() : page.getCreateBy();
|
||||
String draftRealname = resolveUserRealname(draftUsername);
|
||||
if (StringUtils.isBlank(draftRealname)) {
|
||||
draftRealname = page.getCreateBy_dictText();
|
||||
}
|
||||
page.setDraftBy_dictText(draftRealname);
|
||||
}
|
||||
|
||||
private String resolveUserRealname(String username) {
|
||||
if (StringUtils.isBlank(username)) {
|
||||
return null;
|
||||
}
|
||||
SysUser user = sysUserService.getUserByName(username);
|
||||
if (user != null && StringUtils.isNotBlank(user.getRealname())) {
|
||||
return user.getRealname();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
//update-end---author:GHT ---date:2026-06-10 for:【混炼示方】queryById补充起草人/变更人姓名-----------
|
||||
|
||||
private void normalizeMain(MesXslMixingSpec main) {
|
||||
if (main.getDraftTime() == null) {
|
||||
main.setDraftTime(new Date());
|
||||
@@ -289,6 +326,11 @@ public class MesXslMixingSpecServiceImpl extends ServiceImpl<MesXslMixingSpecMap
|
||||
if (main.getDelFlag() == null) {
|
||||
main.setDelFlag(0);
|
||||
}
|
||||
//update-begin---author:cursor ---date:20260608 for:【XSLMES-20260608-A01】混炼示方新增默认编制状态-----------
|
||||
if (StringUtils.isBlank(main.getStatus())) {
|
||||
main.setStatus("compile");
|
||||
}
|
||||
//update-end---author:cursor ---date:20260608 for:【XSLMES-20260608-A01】混炼示方新增默认编制状态-----------
|
||||
}
|
||||
|
||||
private void clearChildren(String mainId) {
|
||||
@@ -479,6 +521,9 @@ public class MesXslMixingSpecServiceImpl extends ServiceImpl<MesXslMixingSpecMap
|
||||
if (TCU_DOWN.equals(row.getSectionType())) {
|
||||
row.setDrugWeighPos(null);
|
||||
}
|
||||
//update-begin---author:GHT ---date:2026-06-10 for:【混炼示方】TCU是否附加为否时清空重量-----------
|
||||
normalizeTcuAttachFields(row);
|
||||
//update-end---author:GHT ---date:2026-06-10 for:【混炼示方】TCU是否附加为否时清空重量-----------
|
||||
rows.add(row);
|
||||
}
|
||||
if (trace != null) {
|
||||
@@ -658,13 +703,16 @@ public class MesXslMixingSpecServiceImpl extends ServiceImpl<MesXslMixingSpecMap
|
||||
if (!hasUp) {
|
||||
MesXslMixingSpecTcu up = new MesXslMixingSpecTcu();
|
||||
up.setSectionType(TCU_UP);
|
||||
up.setIsAttach(TCU_ATTACH_NO);
|
||||
rows.add(0, up);
|
||||
}
|
||||
if (!hasDown) {
|
||||
MesXslMixingSpecTcu down = new MesXslMixingSpecTcu();
|
||||
down.setSectionType(TCU_DOWN);
|
||||
down.setIsAttach(TCU_ATTACH_NO);
|
||||
rows.add(down);
|
||||
}
|
||||
rows.forEach(this::normalizeTcuAttachFields);
|
||||
rows.sort((a, b) -> {
|
||||
int ai = TCU_UP.equals(a.getSectionType()) ? 0 : TCU_DOWN.equals(a.getSectionType()) ? 1 : 2;
|
||||
int bi = TCU_UP.equals(b.getSectionType()) ? 0 : TCU_DOWN.equals(b.getSectionType()) ? 1 : 2;
|
||||
@@ -676,6 +724,20 @@ public class MesXslMixingSpecServiceImpl extends ServiceImpl<MesXslMixingSpecMap
|
||||
return rows;
|
||||
}
|
||||
|
||||
//update-begin---author:GHT ---date:2026-06-10 for:【混炼示方】TCU是否附加为否时清空重量-----------
|
||||
private void normalizeTcuAttachFields(MesXslMixingSpecTcu row) {
|
||||
if (row == null) {
|
||||
return;
|
||||
}
|
||||
if (StringUtils.isBlank(row.getIsAttach())) {
|
||||
row.setIsAttach(TCU_ATTACH_NO);
|
||||
}
|
||||
if (!TCU_ATTACH_YES.equals(row.getIsAttach())) {
|
||||
row.setAttachWeight(null);
|
||||
}
|
||||
}
|
||||
//update-end---author:GHT ---date:2026-06-10 for:【混炼示方】TCU是否附加为否时清空重量-----------
|
||||
|
||||
//update-begin---author:cursor ---date:20260601 for:【XSLMES-20260601-A62】删除混炼示方时同步删除自动生成的B/F段胶料信息-----------
|
||||
/**
|
||||
* 删除混炼示方后,若该 B/F 段胶示方编号已无其它混炼示方、未被其它示方明细引用、且未被配合示方选作胶料代号,
|
||||
|
||||
@@ -18,6 +18,7 @@ import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
import org.jeecg.common.aspect.annotation.Dict;
|
||||
import org.jeecg.modules.print.vo.PrintBizFieldItemVO;
|
||||
import org.jeecgframework.poi.excel.annotation.Excel;
|
||||
|
||||
@@ -46,6 +47,7 @@ public final class PrintBizEntityFieldIntrospector {
|
||||
PrintBizFieldItemVO vo =
|
||||
new PrintBizFieldItemVO(name, resolveLabel(f), "");
|
||||
fillJavaJdbcSimple(vo, f.getType());
|
||||
fillDictMeta(vo, f);
|
||||
ordered.putIfAbsent(name, vo);
|
||||
}
|
||||
c = c.getSuperclass();
|
||||
@@ -223,6 +225,69 @@ public final class PrintBizEntityFieldIntrospector {
|
||||
return f.getName();
|
||||
}
|
||||
|
||||
//update-begin---author:GHT ---date:2026-06-10 for:【钉钉审批模板绑定】扫描@Dict并补全字典元数据-----------
|
||||
/** 扫描 @Dict 注解,供绑定页选择「原值/显示文本」 */
|
||||
public static void fillDictMeta(PrintBizFieldItemVO vo, Field f) {
|
||||
if (vo == null || f == null) {
|
||||
return;
|
||||
}
|
||||
Dict dict = f.getAnnotation(Dict.class);
|
||||
if (dict == null) {
|
||||
vo.setTranslateKind("NONE");
|
||||
return;
|
||||
}
|
||||
if (StringUtils.isNotBlank(dict.dictTable())) {
|
||||
vo.setTranslateKind("TABLE");
|
||||
vo.setDictTable(dict.dictTable().trim());
|
||||
vo.setDictText(StringUtils.isNotBlank(dict.dicText()) ? dict.dicText().trim() : "");
|
||||
vo.setDictCodeField(StringUtils.isNotBlank(dict.dicCode()) ? dict.dicCode().trim() : "id");
|
||||
} else if (StringUtils.isNotBlank(dict.dicCode())) {
|
||||
vo.setTranslateKind("DICT");
|
||||
vo.setDictCode(dict.dicCode().trim());
|
||||
} else {
|
||||
vo.setTranslateKind("NONE");
|
||||
}
|
||||
}
|
||||
|
||||
/** 按字段名在实体类上补全字典元数据(catalog 缓存字段可能缺 translateKind) */
|
||||
public static void enrichDictMeta(List<PrintBizFieldItemVO> fields, Class<?> clazz, String fieldKeyPrefix) {
|
||||
if (fields == null || fields.isEmpty() || clazz == null) {
|
||||
return;
|
||||
}
|
||||
String prefix = StringUtils.isBlank(fieldKeyPrefix) ? "" : fieldKeyPrefix.trim();
|
||||
if (StringUtils.isNotBlank(prefix) && !prefix.endsWith(".")) {
|
||||
prefix = prefix + ".";
|
||||
}
|
||||
Map<String, Field> fieldMap = new LinkedHashMap<>();
|
||||
Class<?> c = clazz;
|
||||
while (c != null && c != Object.class) {
|
||||
for (Field f : c.getDeclaredFields()) {
|
||||
fieldMap.putIfAbsent(f.getName(), f);
|
||||
}
|
||||
c = c.getSuperclass();
|
||||
}
|
||||
for (PrintBizFieldItemVO vo : fields) {
|
||||
if (vo == null || StringUtils.isBlank(vo.getFieldKey())) {
|
||||
continue;
|
||||
}
|
||||
String key = vo.getFieldKey();
|
||||
if (StringUtils.isNotBlank(prefix) && key.startsWith(prefix)) {
|
||||
key = key.substring(prefix.length());
|
||||
}
|
||||
int dot = key.indexOf('.');
|
||||
if (dot >= 0) {
|
||||
key = key.substring(dot + 1);
|
||||
}
|
||||
Field f = fieldMap.get(key);
|
||||
if (f != null) {
|
||||
fillDictMeta(vo, f);
|
||||
} else if (StringUtils.isBlank(vo.getTranslateKind())) {
|
||||
vo.setTranslateKind("NONE");
|
||||
}
|
||||
}
|
||||
}
|
||||
//update-end---author:GHT ---date:2026-06-10 for:【钉钉审批模板绑定】扫描@Dict并补全字典元数据-----------
|
||||
|
||||
/** 按全限定类名加载 Class,失败返回 null */
|
||||
public static Class<?> tryLoadClass(String entityClassFqn) {
|
||||
if (StringUtils.isBlank(entityClassFqn)) {
|
||||
|
||||
@@ -35,6 +35,24 @@ public class PrintBizFieldItemVO implements Serializable {
|
||||
@Schema(description = "简化种类,便于前端格式化")
|
||||
private String simpleKind;
|
||||
|
||||
//update-begin---author:GHT ---date:2026-06-10 for:【钉钉审批模板绑定】字段元数据支持字典/表字典-----------
|
||||
/** 取值翻译类型:NONE=普通字段,DICT=字典,TABLE=表字典(部门/用户等) */
|
||||
@Schema(description = "取值翻译类型:NONE/DICT/TABLE")
|
||||
private String translateKind;
|
||||
|
||||
@Schema(description = "字典编码(dicCode),translateKind=DICT 时有效")
|
||||
private String dictCode;
|
||||
|
||||
@Schema(description = "字典表名,translateKind=TABLE 时有效")
|
||||
private String dictTable;
|
||||
|
||||
@Schema(description = "字典表文本列,translateKind=TABLE 时有效")
|
||||
private String dictText;
|
||||
|
||||
@Schema(description = "字典表编码列,translateKind=TABLE 时有效")
|
||||
private String dictCodeField;
|
||||
//update-end---author:GHT ---date:2026-06-10 for:【钉钉审批模板绑定】字段元数据支持字典/表字典-----------
|
||||
|
||||
/** 兼容旧三参构造(类型字段为空) */
|
||||
public PrintBizFieldItemVO(String fieldKey, String label, String description) {
|
||||
this.fieldKey = fieldKey;
|
||||
@@ -53,6 +71,13 @@ public class PrintBizFieldItemVO implements Serializable {
|
||||
o.setJavaType(src.getJavaType());
|
||||
o.setJdbcType(src.getJdbcType());
|
||||
o.setSimpleKind(src.getSimpleKind());
|
||||
//update-begin---author:GHT ---date:2026-06-10 for:【钉钉审批模板绑定】复制字段时保留字典元数据-----------
|
||||
o.setTranslateKind(src.getTranslateKind());
|
||||
o.setDictCode(src.getDictCode());
|
||||
o.setDictTable(src.getDictTable());
|
||||
o.setDictText(src.getDictText());
|
||||
o.setDictCodeField(src.getDictCodeField());
|
||||
//update-end---author:GHT ---date:2026-06-10 for:【钉钉审批模板绑定】复制字段时保留字典元数据-----------
|
||||
}
|
||||
return o;
|
||||
}
|
||||
|
||||
@@ -56,6 +56,11 @@ import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.messaging.simp.SimpMessagingTemplate;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
//update-begin---author:GHT ---date:2026-06-08 for:【XSLMES-20260608】同步钉钉ID功能-----------
|
||||
import com.jeecg.dingtalk.api.core.response.Response;
|
||||
import com.jeecg.dingtalk.api.user.JdtUserAPI;
|
||||
import org.jeecg.modules.system.service.impl.ThirdAppDingtalkServiceImpl;
|
||||
//update-end---author:GHT ---date:2026-06-08 for:【XSLMES-20260608】同步钉钉ID功能-----------
|
||||
import org.springframework.web.servlet.ModelAndView;
|
||||
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
@@ -125,6 +130,11 @@ public class SysUserController {
|
||||
@Autowired(required = false)
|
||||
private SimpMessagingTemplate simpMessagingTemplate;
|
||||
|
||||
//update-begin---author:GHT ---date:2026-06-08 for:【XSLMES-20260608】注入钉钉服务,用于同步钉钉ID-----------
|
||||
@Autowired(required = false)
|
||||
private ThirdAppDingtalkServiceImpl thirdAppDingtalkService;
|
||||
//update-end---author:GHT ---date:2026-06-08 for:【XSLMES-20260608】注入钉钉服务,用于同步钉钉ID-----------
|
||||
|
||||
private void notifyScadaUserChanged(String action, String userId) {
|
||||
try {
|
||||
JSONObject payload = new JSONObject();
|
||||
@@ -2246,6 +2256,60 @@ public class SysUserController {
|
||||
* yes_{URL编码后的默认密码} -> 用户当前密码为默认初始密码,前端需弹出强制修改提示
|
||||
* no -> 用户密码不是默认密码,或未开启默认密码检测开关
|
||||
*/
|
||||
//update-begin---author:GHT ---date:2026-06-08 for:【XSLMES-20260608】批量同步钉钉用户ID接口-----------
|
||||
/**
|
||||
* 批量同步钉钉ID:根据用户手机号查询钉钉userId并回写到sys_user.ding_user_id。
|
||||
* 匹配不到的用户只做提示,不影响其他用户继续匹配。
|
||||
*/
|
||||
@Operation(summary = "同步钉钉用户ID")
|
||||
@PostMapping("/syncDingUserId")
|
||||
public Result<JSONObject> syncDingUserId() {
|
||||
if (thirdAppDingtalkService == null) {
|
||||
return Result.error("钉钉集成未配置,无法同步");
|
||||
}
|
||||
String accessToken = thirdAppDingtalkService.getAccessTokenForBackground();
|
||||
if (oConvertUtils.isEmpty(accessToken)) {
|
||||
return Result.error("获取钉钉 AccessToken 失败,请检查钉钉应用配置");
|
||||
}
|
||||
// 查询所有有手机号的用户
|
||||
List<SysUser> users = sysUserService.list(
|
||||
new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<SysUser>()
|
||||
.isNotNull(SysUser::getPhone)
|
||||
.ne(SysUser::getPhone, "")
|
||||
.eq(SysUser::getDelFlag, 0)
|
||||
);
|
||||
int successCount = 0;
|
||||
int failCount = 0;
|
||||
List<String> failDetails = new ArrayList<>();
|
||||
for (SysUser user : users) {
|
||||
try {
|
||||
Response<String> resp = JdtUserAPI.getUseridByMobile(user.getPhone(), accessToken);
|
||||
if (resp != null && resp.isSuccess() && oConvertUtils.isNotEmpty(resp.getResult())) {
|
||||
sysUserService.lambdaUpdate()
|
||||
.eq(SysUser::getId, user.getId())
|
||||
.set(SysUser::getDingUserId, resp.getResult())
|
||||
.update();
|
||||
successCount++;
|
||||
} else {
|
||||
failCount++;
|
||||
failDetails.add(user.getRealname() + "(" + user.getPhone() + ")");
|
||||
log.info("[syncDingUserId] 手机号未匹配到钉钉用户 realname={} phone={}", user.getRealname(), user.getPhone());
|
||||
}
|
||||
} catch (Exception e) {
|
||||
failCount++;
|
||||
failDetails.add(user.getRealname() + "(" + user.getPhone() + ")");
|
||||
log.warn("[syncDingUserId] 查询钉钉ID异常 realname={} phone={}", user.getRealname(), user.getPhone(), e);
|
||||
}
|
||||
}
|
||||
JSONObject result = new JSONObject();
|
||||
result.put("successCount", successCount);
|
||||
result.put("failCount", failCount);
|
||||
result.put("failDetails", failDetails);
|
||||
String msg = "同步完成:成功 " + successCount + " 人,未匹配 " + failCount + " 人";
|
||||
return Result.OK(msg, result);
|
||||
}
|
||||
//update-end---author:GHT ---date:2026-06-08 for:【XSLMES-20260608】批量同步钉钉用户ID接口-----------
|
||||
|
||||
@GetMapping("/verifyIzDefaultPwd")
|
||||
public Result<String> verifyIzDefaultPwd() throws UnsupportedEncodingException {
|
||||
// 未配置 Firewall 或已关闭默认密码检测开关 (enableDefaultPwdCheck=false) 时,直接返回 "no" 表示无需提示
|
||||
|
||||
@@ -32,8 +32,12 @@ import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import java.net.HttpURLConnection;
|
||||
import java.net.InetAddress;
|
||||
import java.net.NetworkInterface;
|
||||
import java.net.URL;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.Enumeration;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
@@ -72,6 +72,17 @@ public class SysThirdAppConfig {
|
||||
private Integer streamEnabled;
|
||||
//update-end---author:GHT ---date:20260604 for:【钉钉Stream回调】Stream事件推送主配置标识-----
|
||||
|
||||
//update-begin---author:GHT ---date:20260609 for:【钉钉Stream开发】第三方配置页Stream接收节点可视化-----------
|
||||
@Schema(description = "是否限制仅指定节点接收Stream(0-否,1-是)")
|
||||
private Integer streamReceiverEnabled;
|
||||
@Schema(description = "允许接收Stream的IP白名单,逗号分隔")
|
||||
private String streamDesignatedIps;
|
||||
@Schema(description = "允许接收Stream的主机名白名单,逗号分隔")
|
||||
private String streamDesignatedHosts;
|
||||
@Schema(description = "Stream集群Redis选主(0-否,1-是)")
|
||||
private Integer streamClusterMode;
|
||||
//update-end---author:GHT ---date:20260609 for:【钉钉Stream开发】第三方配置页Stream接收节点可视化-----------
|
||||
|
||||
/**创建日期*/
|
||||
@Excel(name = "创建日期", width = 20, format = "yyyy-MM-dd HH:mm:ss")
|
||||
@JsonFormat(timezone = "GMT+8",pattern = "yyyy-MM-dd HH:mm:ss")
|
||||
|
||||
@@ -272,4 +272,11 @@ public class SysUser implements Serializable {
|
||||
*/
|
||||
@TableField(exist = false)
|
||||
private String belongDepIds;
|
||||
|
||||
//update-begin---author:GHT ---date:2026-06-08 for:【XSLMES-20260608】新增钉钉用户ID字段,支持同步钉钉ID功能-----------
|
||||
/**
|
||||
* 钉钉用户ID
|
||||
*/
|
||||
private String dingUserId;
|
||||
//update-end---author:GHT ---date:2026-06-08 for:【XSLMES-20260608】新增钉钉用户ID字段,支持同步钉钉ID功能-----------
|
||||
}
|
||||
|
||||
@@ -1228,6 +1228,29 @@ public class ThirdAppDingtalkServiceImpl implements IThirdAppService {
|
||||
}
|
||||
//update-end---author:GHT ---date:20260604 for:【钉钉Stream回调】获取钉钉应用凭证(Stream模式专用)-----
|
||||
|
||||
//update-begin---author:GHT ---date:20260609 for:【钉钉Stream开发】获取Stream主配置(含节点白名单)-----------
|
||||
/**
|
||||
* 获取 Stream 主配置记录(stream_enabled=1 优先)。
|
||||
*/
|
||||
public SysThirdAppConfig getStreamMasterConfig() {
|
||||
java.util.List<SysThirdAppConfig> all = configMapper.selectList(
|
||||
new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<SysThirdAppConfig>()
|
||||
.eq(SysThirdAppConfig::getThirdType, THIRD_TYPE)
|
||||
.eq(SysThirdAppConfig::getStatus, 1)
|
||||
.orderByDesc(SysThirdAppConfig::getStreamEnabled)
|
||||
.orderByDesc(SysThirdAppConfig::getTenantId));
|
||||
if (all == null || all.isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
for (SysThirdAppConfig c : all) {
|
||||
if (c.getStreamEnabled() != null && c.getStreamEnabled() == 1) {
|
||||
return c;
|
||||
}
|
||||
}
|
||||
return all.get(0);
|
||||
}
|
||||
//update-end---author:GHT ---date:20260609 for:【钉钉Stream开发】获取Stream主配置(含节点白名单)-----------
|
||||
|
||||
//update-begin---author:GHT ---date:20260604 for:【钉钉Stream回调】后台线程专用AccessToken(绕过租户检查)-----
|
||||
/**
|
||||
* 后台线程专用:获取钉钉 AccessToken,不依赖 TenantContext。
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
# 复制为 application-dev-local.yml(同目录),仅本机生效,勿提交 Git
|
||||
# 作用:只有你这台电脑接收钉钉 Stream 回调与补偿扫描,其他共用 dev 库的机器不会抢消息
|
||||
#
|
||||
# 1. 复制:application-dev-local.yml.example -> application-dev-local.yml
|
||||
# 2. 查本机 IP(PowerShell: ipconfig,看 IPv4 地址)
|
||||
# 3. 将 designated-ips 改成你的 IP(推荐,比主机名稳定)
|
||||
# 4. 重启 JeecgBoot
|
||||
#
|
||||
# 启动后若未匹配,日志会打印 localIps=... 便于核对
|
||||
|
||||
jeecg:
|
||||
xslmes:
|
||||
dingtalk:
|
||||
stream:
|
||||
receiver-enabled: true
|
||||
# 推荐:仅 IP 白名单(二选一或同时配置,满足其一即可)
|
||||
designated-ips:
|
||||
- 192.168.1.100
|
||||
# 可选:主机名白名单(hostname 命令查看)
|
||||
# designated-hosts:
|
||||
# - LAPTOP-9LEM1NNJ
|
||||
@@ -20,6 +20,9 @@ management:
|
||||
include: metrics,httpexchanges,jeecghttptrace
|
||||
|
||||
spring:
|
||||
config:
|
||||
# 开发者本机覆盖配置(不提交 Git),用于指定仅本机接收钉钉 Stream 回调
|
||||
import: optional:classpath:application-dev-local.yml
|
||||
# main:
|
||||
# # 启动加速 (建议开发环境,开启后flyway自动升级失效)
|
||||
# lazy-initialization: true
|
||||
@@ -211,6 +214,20 @@ mybatis-plus:
|
||||
minidao:
|
||||
base-package: org.jeecg.modules.jmreport.*,org.jeecg.modules.drag.*
|
||||
jeecg:
|
||||
xslmes:
|
||||
dingtalk:
|
||||
stream:
|
||||
# 共享 dev 默认关闭;仅本机在 application-dev-local.yml 开启(见 application-dev-local.yml.example)
|
||||
receiver-enabled: false
|
||||
# 白名单二选一或同时配置(满足其一即可);推荐 designated-ips,比主机名稳定
|
||||
designated-hosts: []
|
||||
designated-ips: []
|
||||
# 多实例部署务必 true:Redis 选主,仅 Leader 建 Stream 长连接
|
||||
cluster-mode: true
|
||||
leader-renew-interval-ms: 10000
|
||||
follower-retry-interval-ms: 15000
|
||||
health-log-interval-ms: 60000
|
||||
idle-warn-seconds: 1800
|
||||
# 自定义资源请求前缀(js、css等解决nginx转发问题)
|
||||
custom-resource-prefix-path:
|
||||
# AI集成
|
||||
|
||||
@@ -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;
|
||||
@@ -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());
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user