From 1d0b4c9fbbc44478d1fcf6d5b3cf1e9b487b90e9 Mon Sep 17 00:00:00 2001 From: geht <2947093423@qq.com> Date: Fri, 5 Jun 2026 19:05:48 +0800 Subject: [PATCH 1/7] =?UTF-8?q?=E5=A2=9E=E5=BC=BA=E5=AE=A1=E6=89=B9?= =?UTF-8?q?=E6=B5=81=E7=AE=A1=E7=90=86=E8=83=BD=E5=8A=9B=EF=BC=8C=E6=96=B0?= =?UTF-8?q?=E5=A2=9E=E5=AE=A1=E6=89=B9=E7=8E=AF=E8=8A=82=E7=9A=84=20stageK?= =?UTF-8?q?ey=20=E5=8C=BA=E5=88=86=E5=85=B3=E9=94=AE=E7=8E=AF=E8=8A=82?= =?UTF-8?q?=E4=B8=8E=E8=BF=87=E8=B7=AF=E5=AE=A1=E6=89=B9=E8=8A=82=E7=82=B9?= =?UTF-8?q?=EF=BC=8C=E5=AE=8C=E5=96=84=E9=92=89=E9=92=89=E5=9B=9E=E8=B0=83?= =?UTF-8?q?=E6=97=A5=E5=BF=97=E8=AE=B0=E5=BD=95=EF=BC=8C=E5=81=9C=E7=94=A8?= =?UTF-8?q?=E9=83=A8=E5=88=86=20HTTP=20=E5=9B=9E=E8=B0=83=E6=8E=A5?= =?UTF-8?q?=E5=8F=A3=EF=BC=8C=E6=94=B9=E7=94=B1=E9=9B=86=E6=88=90=E6=96=B9?= =?UTF-8?q?=E6=A1=88=E9=A9=B1=E5=8A=A8=E5=AE=A1=E6=89=B9=E6=B5=81=EF=BC=8C?= =?UTF-8?q?=E4=BC=98=E5=8C=96=E5=AE=A1=E6=89=B9=E6=B3=A8=E5=86=8C=E4=B8=AD?= =?UTF-8?q?=E5=BF=83=E7=9A=84=E6=9F=A5=E8=AF=A2=E9=80=BB=E8=BE=91=E3=80=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../jeecg-module-xslmes/doc/代码修改日志 | 112 +++ .../doc/审核集成功能整体方案计划.md | 668 ++++++++++++++++ .../jeecg-module-xslmes/pom.xml | 4 +- .../callback/ApprovalCallbackContext.java | 10 + .../callback/ApprovalCallbackDispatcher.java | 55 +- .../MesXslApprovalFlowController.java | 119 ++- .../approval/entity/MesXslApprovalRecord.java | 9 + .../MesXslApprovalTraceController.java | 72 ++ .../MesXslBizDocRegistryController.java | 84 ++ .../MesXslIntegrationLogController.java | 61 ++ .../MesXslIntegrationPlanController.java | 216 +++++ .../engine/ApprovalStageResolver.java | 156 ++++ .../engine/IntegrationBizCallback.java | 81 ++ .../engine/IntegrationContext.java | 42 + .../engine/IntegrationOrchestrator.java | 515 ++++++++++++ .../engine/RegistryStageFieldHelper.java | 69 ++ .../integration/engine/TriggerPhase.java | 20 + .../integration/engine/VariableResolver.java | 125 +++ .../executor/IIntegrationActionExecutor.java | 19 + .../executor/RegistryStageRevertExecutor.java | 105 +++ .../executor/RegistryStageSyncExecutor.java | 155 ++++ .../executor/SqlUpdateActionExecutor.java | 70 ++ .../entity/MesXslApprovalTrace.java | 76 ++ .../entity/MesXslBizDocRegistry.java | 84 ++ .../entity/MesXslIntegrationAction.java | 66 ++ .../entity/MesXslIntegrationLog.java | 76 ++ .../entity/MesXslIntegrationPlan.java | 76 ++ .../mapper/MesXslApprovalTraceMapper.java | 10 + .../mapper/MesXslBizDocRegistryMapper.java | 7 + .../mapper/MesXslIntegrationActionMapper.java | 7 + .../mapper/MesXslIntegrationLogMapper.java | 7 + .../mapper/MesXslIntegrationPlanMapper.java | 7 + .../mapper/xml/MesXslApprovalTraceMapper.xml | 4 + .../mapper/xml/MesXslBizDocRegistryMapper.xml | 4 + .../xml/MesXslIntegrationActionMapper.xml | 4 + .../mapper/xml/MesXslIntegrationLogMapper.xml | 4 + .../xml/MesXslIntegrationPlanMapper.xml | 4 + .../service/IApprovalTraceSyncService.java | 26 + .../service/IMesXslApprovalTraceService.java | 15 + .../service/IMesXslBizDocRegistryService.java | 17 + .../IMesXslIntegrationActionService.java | 15 + .../service/IMesXslIntegrationLogService.java | 14 + .../IMesXslIntegrationPlanService.java | 19 + .../service/IntegrationPlanGenerator.java | 657 +++++++++++++++ .../impl/ApprovalTraceSyncServiceImpl.java | 144 ++++ .../impl/MesXslApprovalTraceServiceImpl.java | 27 + .../impl/MesXslBizDocRegistryServiceImpl.java | 71 ++ .../MesXslIntegrationActionServiceImpl.java | 30 + .../impl/MesXslIntegrationLogServiceImpl.java | 29 + .../MesXslIntegrationPlanServiceImpl.java | 103 +++ .../impl/MesXslApprovalHandleServiceImpl.java | 43 +- .../MesXslMixerPsCompileController.java | 12 +- .../stream/DingBpmsEventProcessor.java | 285 +++++-- .../dingtalk/stream/DingTalkStreamClient.java | 93 +-- .../stream/DingTalkStreamSdkRunner.java | 109 +++ .../stream/DingTalkWorkflowService.java | 52 +- .../impl/MesXslMixerPsCompileServiceImpl.java | 20 + .../jeecg-system-start/pom.xml | 6 + ...V3.9.2_130__mes_xsl_integration_tables.sql | 115 +++ .../V3.9.2_131__mes_xsl_integration_dict.sql | 74 ++ .../V3.9.2_132__mes_xsl_integration_menu.sql | 50 ++ ..._133__mes_xsl_integration_demo_mixerps.sql | 106 +++ ...134__mes_xsl_integration_action_config.sql | 5 + .../V3.9.2_135__mes_xsl_approval_registry.sql | 132 +++ ...6__mes_xsl_biz_doc_registry_trace_perm.sql | 26 + ...7__mes_xsl_integration_plan_stage_bind.sql | 32 + ...3.9.2_138__mes_xsl_registry_stage_sync.sql | 87 ++ .../src/components/ApprovalDesign/index.vue | 4 +- .../views/approval/flow/ApprovalFlowList.vue | 22 +- .../views/approval/flow/approvalFlow.api.ts | 15 +- .../approval/flow/components/FlowDesign.vue | 26 +- .../flow/components/NodeConfigDrawer.vue | 288 ++++--- .../views/approval/flow/components/flow.less | 8 + .../approval/flow/components/flowTypes.ts | 31 +- .../integration/MesXslApprovalTrace.api.ts | 15 + .../integration/MesXslApprovalTrace.data.ts | 25 + .../integration/MesXslApprovalTraceList.vue | 42 + .../integration/MesXslBizDocRegistry.api.ts | 32 + .../integration/MesXslBizDocRegistry.data.ts | 134 ++++ .../integration/MesXslBizDocRegistryList.vue | 104 +++ .../integration/MesXslIntegrationLog.api.ts | 9 + .../integration/MesXslIntegrationLog.data.ts | 30 + .../integration/MesXslIntegrationLogList.vue | 87 ++ .../integration/MesXslIntegrationPlan.api.ts | 57 ++ .../integration/MesXslIntegrationPlan.data.ts | 156 ++++ .../integration/MesXslIntegrationPlanList.vue | 150 ++++ .../MesXslIntegrationPlanWizard.vue | 466 +++++++++++ .../components/GenerateDefaultPlanModal.vue | 332 ++++++++ .../components/MesXslApprovalTraceDrawer.vue | 49 ++ .../components/MesXslBizDocRegistryModal.vue | 69 ++ .../MesXslIntegrationActionDrawer.vue | 195 +++++ .../components/MesXslIntegrationPlanModal.vue | 46 ++ .../components/VisualActionEditor.vue | 753 ++++++++++++++++++ .../MesXslMixerPsCompile.api.ts | 18 +- .../MesXslMixerPsCompileList.vue | 62 +- 95 files changed, 8385 insertions(+), 457 deletions(-) create mode 100644 jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/doc/审核集成功能整体方案计划.md create mode 100644 jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/integration/controller/MesXslApprovalTraceController.java create mode 100644 jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/integration/controller/MesXslBizDocRegistryController.java create mode 100644 jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/integration/controller/MesXslIntegrationLogController.java create mode 100644 jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/integration/controller/MesXslIntegrationPlanController.java create mode 100644 jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/integration/engine/ApprovalStageResolver.java create mode 100644 jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/integration/engine/IntegrationBizCallback.java create mode 100644 jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/integration/engine/IntegrationContext.java create mode 100644 jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/integration/engine/IntegrationOrchestrator.java create mode 100644 jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/integration/engine/RegistryStageFieldHelper.java create mode 100644 jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/integration/engine/TriggerPhase.java create mode 100644 jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/integration/engine/VariableResolver.java create mode 100644 jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/integration/engine/executor/IIntegrationActionExecutor.java create mode 100644 jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/integration/engine/executor/RegistryStageRevertExecutor.java create mode 100644 jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/integration/engine/executor/RegistryStageSyncExecutor.java create mode 100644 jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/integration/engine/executor/SqlUpdateActionExecutor.java create mode 100644 jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/integration/entity/MesXslApprovalTrace.java create mode 100644 jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/integration/entity/MesXslBizDocRegistry.java create mode 100644 jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/integration/entity/MesXslIntegrationAction.java create mode 100644 jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/integration/entity/MesXslIntegrationLog.java create mode 100644 jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/integration/entity/MesXslIntegrationPlan.java create mode 100644 jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/integration/mapper/MesXslApprovalTraceMapper.java create mode 100644 jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/integration/mapper/MesXslBizDocRegistryMapper.java create mode 100644 jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/integration/mapper/MesXslIntegrationActionMapper.java create mode 100644 jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/integration/mapper/MesXslIntegrationLogMapper.java create mode 100644 jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/integration/mapper/MesXslIntegrationPlanMapper.java create mode 100644 jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/integration/mapper/xml/MesXslApprovalTraceMapper.xml create mode 100644 jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/integration/mapper/xml/MesXslBizDocRegistryMapper.xml create mode 100644 jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/integration/mapper/xml/MesXslIntegrationActionMapper.xml create mode 100644 jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/integration/mapper/xml/MesXslIntegrationLogMapper.xml create mode 100644 jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/integration/mapper/xml/MesXslIntegrationPlanMapper.xml create mode 100644 jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/integration/service/IApprovalTraceSyncService.java create mode 100644 jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/integration/service/IMesXslApprovalTraceService.java create mode 100644 jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/integration/service/IMesXslBizDocRegistryService.java create mode 100644 jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/integration/service/IMesXslIntegrationActionService.java create mode 100644 jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/integration/service/IMesXslIntegrationLogService.java create mode 100644 jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/integration/service/IMesXslIntegrationPlanService.java create mode 100644 jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/integration/service/IntegrationPlanGenerator.java create mode 100644 jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/integration/service/impl/ApprovalTraceSyncServiceImpl.java create mode 100644 jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/integration/service/impl/MesXslApprovalTraceServiceImpl.java create mode 100644 jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/integration/service/impl/MesXslBizDocRegistryServiceImpl.java create mode 100644 jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/integration/service/impl/MesXslIntegrationActionServiceImpl.java create mode 100644 jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/integration/service/impl/MesXslIntegrationLogServiceImpl.java create mode 100644 jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/integration/service/impl/MesXslIntegrationPlanServiceImpl.java create mode 100644 jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/dingtalk/stream/DingTalkStreamSdkRunner.java create mode 100644 jeecg-boot/jeecg-module-system/jeecg-system-start/src/main/resources/flyway/sql/mysql/V3.9.2_130__mes_xsl_integration_tables.sql create mode 100644 jeecg-boot/jeecg-module-system/jeecg-system-start/src/main/resources/flyway/sql/mysql/V3.9.2_131__mes_xsl_integration_dict.sql create mode 100644 jeecg-boot/jeecg-module-system/jeecg-system-start/src/main/resources/flyway/sql/mysql/V3.9.2_132__mes_xsl_integration_menu.sql create mode 100644 jeecg-boot/jeecg-module-system/jeecg-system-start/src/main/resources/flyway/sql/mysql/V3.9.2_133__mes_xsl_integration_demo_mixerps.sql create mode 100644 jeecg-boot/jeecg-module-system/jeecg-system-start/src/main/resources/flyway/sql/mysql/V3.9.2_134__mes_xsl_integration_action_config.sql create mode 100644 jeecg-boot/jeecg-module-system/jeecg-system-start/src/main/resources/flyway/sql/mysql/V3.9.2_135__mes_xsl_approval_registry.sql create mode 100644 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 create mode 100644 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 create mode 100644 jeecg-boot/jeecg-module-system/jeecg-system-start/src/main/resources/flyway/sql/mysql/V3.9.2_138__mes_xsl_registry_stage_sync.sql create mode 100644 jeecgboot-vue3/src/views/xslmes/approval/integration/MesXslApprovalTrace.api.ts create mode 100644 jeecgboot-vue3/src/views/xslmes/approval/integration/MesXslApprovalTrace.data.ts create mode 100644 jeecgboot-vue3/src/views/xslmes/approval/integration/MesXslApprovalTraceList.vue create mode 100644 jeecgboot-vue3/src/views/xslmes/approval/integration/MesXslBizDocRegistry.api.ts create mode 100644 jeecgboot-vue3/src/views/xslmes/approval/integration/MesXslBizDocRegistry.data.ts create mode 100644 jeecgboot-vue3/src/views/xslmes/approval/integration/MesXslBizDocRegistryList.vue create mode 100644 jeecgboot-vue3/src/views/xslmes/approval/integration/MesXslIntegrationLog.api.ts create mode 100644 jeecgboot-vue3/src/views/xslmes/approval/integration/MesXslIntegrationLog.data.ts create mode 100644 jeecgboot-vue3/src/views/xslmes/approval/integration/MesXslIntegrationLogList.vue create mode 100644 jeecgboot-vue3/src/views/xslmes/approval/integration/MesXslIntegrationPlan.api.ts create mode 100644 jeecgboot-vue3/src/views/xslmes/approval/integration/MesXslIntegrationPlan.data.ts create mode 100644 jeecgboot-vue3/src/views/xslmes/approval/integration/MesXslIntegrationPlanList.vue create mode 100644 jeecgboot-vue3/src/views/xslmes/approval/integration/MesXslIntegrationPlanWizard.vue create mode 100644 jeecgboot-vue3/src/views/xslmes/approval/integration/components/GenerateDefaultPlanModal.vue create mode 100644 jeecgboot-vue3/src/views/xslmes/approval/integration/components/MesXslApprovalTraceDrawer.vue create mode 100644 jeecgboot-vue3/src/views/xslmes/approval/integration/components/MesXslBizDocRegistryModal.vue create mode 100644 jeecgboot-vue3/src/views/xslmes/approval/integration/components/MesXslIntegrationActionDrawer.vue create mode 100644 jeecgboot-vue3/src/views/xslmes/approval/integration/components/MesXslIntegrationPlanModal.vue create mode 100644 jeecgboot-vue3/src/views/xslmes/approval/integration/components/VisualActionEditor.vue diff --git a/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/doc/代码修改日志 b/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/doc/代码修改日志 index 429c12e3..03aaee78 100644 --- a/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/doc/代码修改日志 +++ b/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/doc/代码修改日志 @@ -708,3 +708,115 @@ jeecgboot-vue3/src/views/xslmes/approval/mesXslApprovalRecord/components/MesXslA jeecg-boot/jeecg-module-system/jeecg-system-biz/src/main/java/org/jeecg/modules/system/service/impl/ThirdAppDingtalkServiceImpl.java jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/dingtalk/stream/DingBpmsEventProcessor.java jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/dingtalk/stream/DingTalkStreamClient.java + +-- author:GHT---date:20260605--for: 【XSLMES-20260605-K8R2】修复审批注册启用列不显示、清空启用环节无法保存 ----- +jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/integration/entity/MesXslBizDocRegistry.java +jeecgboot-vue3/src/views/xslmes/approval/integration/MesXslBizDocRegistry.data.ts +jeecgboot-vue3/src/views/xslmes/approval/integration/components/MesXslBizDocRegistryModal.vue + +-- author:GHT---date:20260605--for: 【XSLMES-20260605-K8R2】审批注册中心操作列新增查看明细抽屉 ----- +jeecgboot-vue3/src/views/xslmes/approval/integration/components/MesXslApprovalTraceDrawer.vue +jeecgboot-vue3/src/views/xslmes/approval/integration/MesXslApprovalTrace.data.ts +jeecgboot-vue3/src/views/xslmes/approval/integration/MesXslBizDocRegistryList.vue + +-- author:GHT---date:20260605--for: 【XSLMES-20260605-K8R2】查看明细按钮改用注册中心list权限并放宽接口鉴权 ----- +jeecgboot-vue3/src/views/xslmes/approval/integration/MesXslBizDocRegistryList.vue +jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/integration/controller/MesXslApprovalTraceController.java + +-- author:GHT---date:20260605--for: 【XSLMES-20260605-K8R2】审批注册中心环节同步/回退执行器+密炼PS无代码方案 ----- +jeecg-boot/jeecg-module-system/jeecg-system-start/src/main/resources/flyway/sql/mysql/V3.9.2_138__mes_xsl_registry_stage_sync.sql +jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/integration/engine/RegistryStageFieldHelper.java +jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/integration/engine/executor/RegistryStageSyncExecutor.java +jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/integration/engine/executor/RegistryStageRevertExecutor.java +jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/integration/engine/IntegrationContext.java +jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/integration/engine/IntegrationOrchestrator.java +jeecgboot-vue3/src/views/xslmes/approval/integration/components/VisualActionEditor.vue +jeecgboot-vue3/src/views/xslmes/approval/integration/MesXslIntegrationPlanWizard.vue + +-- author:GHT---date:20260605--for: 【XSLMES-20260605-K8R2】审批流设计环节改读审批注册中心 ----- +jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/controller/MesXslApprovalFlowController.java +jeecgboot-vue3/src/views/approval/flow/approvalFlow.api.ts +jeecgboot-vue3/src/views/approval/flow/ApprovalFlowList.vue +jeecgboot-vue3/src/views/approval/flow/components/FlowDesign.vue +jeecgboot-vue3/src/views/approval/flow/components/flowTypes.ts +jeecgboot-vue3/src/components/ApprovalDesign/index.vue + +-- author:GHT---date:20260605--for: 【XSLMES-20260605-K8R2】集成方案与审批注册中心环节绑定 ----- +jeecg-boot/jeecg-module-system/jeecg-system-start/src/main/resources/flyway/sql/mysql/V3.9.2_137__mes_xsl_integration_plan_stage_bind.sql +jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/integration/entity/MesXslIntegrationPlan.java +jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/integration/engine/ApprovalStageResolver.java +jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/integration/engine/IntegrationOrchestrator.java +jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/integration/service/IMesXslBizDocRegistryService.java +jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/integration/service/IMesXslIntegrationPlanService.java +jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/integration/service/impl/MesXslBizDocRegistryServiceImpl.java +jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/integration/service/impl/MesXslIntegrationPlanServiceImpl.java +jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/integration/controller/MesXslIntegrationPlanController.java +jeecgboot-vue3/src/views/xslmes/approval/integration/MesXslIntegrationPlanWizard.vue +jeecgboot-vue3/src/views/xslmes/approval/integration/MesXslIntegrationPlan.data.ts +jeecgboot-vue3/src/views/xslmes/approval/integration/MesXslIntegrationPlan.api.ts + +-- author:GHT---date:20260605--for: 【XSLMES-20260605-K8R2】钉钉校对通过后原单状态同步修复 ----- +jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/dingtalk/stream/DingBpmsEventProcessor.java +jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/integration/engine/IntegrationOrchestrator.java +jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/integration/engine/ApprovalStageResolver.java + +-- author:GHT---date:20260605--for: 【XSLMES-20260605-K8R2】审批环节同步前置状态改字典下拉 ----- +jeecgboot-vue3/src/views/xslmes/approval/integration/components/VisualActionEditor.vue + +-- author:GHT---date:20260605--for: 【XSLMES-20260605-K8R2】VisualActionEditor兼容Flyway扁平actionConfig ----- +jeecgboot-vue3/src/views/xslmes/approval/integration/components/VisualActionEditor.vue + +-- author:GHT---date:20260605--for: 【XSLMES-20260605-K8R2】集成方案动作管理复用可视化编辑器 ----- +jeecgboot-vue3/src/views/xslmes/approval/integration/components/MesXslIntegrationActionDrawer.vue + +-- author:GHT---date:20260605--for: 【XSLMES-20260605-K8R2】密炼PS操作接口停用+流程节点改绑集成方案 ----- +jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/controller/MesXslMixerPsCompileController.java +jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/service/impl/MesXslApprovalHandleServiceImpl.java +jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/dingtalk/stream/DingBpmsEventProcessor.java +jeecgboot-vue3/src/views/xslmes/mesXslMixerPsCompile/MesXslMixerPsCompileList.vue +jeecgboot-vue3/src/views/xslmes/mesXslMixerPsCompile/MesXslMixerPsCompile.api.ts +jeecgboot-vue3/src/views/approval/flow/components/NodeConfigDrawer.vue +jeecgboot-vue3/src/views/approval/flow/approvalFlow.api.ts + +-- author:GHT---date:20260605--for: 【XSLMES-20260605-K8R2】审批注册中心新增查看明细菜单按钮权限并自动授权 ----- +jeecg-boot/jeecg-module-system/jeecg-system-start/src/main/resources/flyway/sql/mysql/V3.9.2_136__mes_xsl_biz_doc_registry_trace_perm.sql +jeecgboot-vue3/src/views/xslmes/approval/integration/MesXslBizDocRegistryList.vue +jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/integration/controller/MesXslApprovalTraceController.java + +-- author:GHT---date:20260605--for: 【XSLMES-20260605-K8R2】末节点批准方案可在流程设计器下拉中选择 ----- +jeecgboot-vue3/src/views/approval/flow/components/NodeConfigDrawer.vue +jeecgboot-vue3/src/views/approval/flow/components/FlowDesign.vue +jeecgboot-vue3/src/views/approval/flow/components/flowTypes.ts +jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/integration/service/IntegrationPlanGenerator.java +jeecgboot-vue3/src/views/xslmes/approval/integration/components/GenerateDefaultPlanModal.vue + +-- author:GHT---date:20260605--for: 【XSLMES-20260605-K8R2】识别环节支持手选且可为空 ----- +jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/integration/service/IntegrationPlanGenerator.java +jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/integration/controller/MesXslIntegrationPlanController.java +jeecgboot-vue3/src/views/xslmes/approval/integration/components/GenerateDefaultPlanModal.vue +jeecgboot-vue3/src/views/xslmes/approval/integration/MesXslIntegrationPlan.api.ts + +-- author:GHT---date:20260605--for: 【XSLMES-20260605-K8R2】按实际流程节点生成并展示环节配置状态 ----- +jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/integration/service/IntegrationPlanGenerator.java +jeecgboot-vue3/src/views/xslmes/approval/integration/components/GenerateDefaultPlanModal.vue +jeecgboot-vue3/src/views/approval/flow/components/flowTypes.ts + +-- author:GHT---date:20260605--for: 【XSLMES-20260605-K8R2】按审批流程节点一键生成默认集成方案 ----- +jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/integration/service/IntegrationPlanGenerator.java +jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/integration/controller/MesXslIntegrationPlanController.java +jeecgboot-vue3/src/views/xslmes/approval/integration/MesXslIntegrationPlanList.vue +jeecgboot-vue3/src/views/xslmes/approval/integration/MesXslIntegrationPlan.api.ts +jeecgboot-vue3/src/views/xslmes/approval/integration/components/GenerateDefaultPlanModal.vue +jeecgboot-vue3/src/views/xslmes/approval/integration/components/VisualActionEditor.vue + +-- author:GHT---date:20260605--for: 【XSLMES-20260605-K8R2】多轮审批集成幂等修复(台账recordId+回退强制重跑) ----- +jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/integration/engine/IntegrationOrchestrator.java + +-- author:GHT---date:20260605--for: 【XSLMES-20260605-K8R2】钉钉回调全链路统一日志前缀[钉钉回调] ----- +jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/dingtalk/stream/DingTalkStreamSdkRunner.java +jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/dingtalk/stream/DingTalkStreamClient.java +jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/dingtalk/stream/DingBpmsEventProcessor.java +jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/dingtalk/stream/DingTalkWorkflowService.java +jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/callback/ApprovalCallbackDispatcher.java +jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/integration/engine/IntegrationBizCallback.java +jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/integration/engine/IntegrationOrchestrator.java diff --git a/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/doc/审核集成功能整体方案计划.md b/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/doc/审核集成功能整体方案计划.md new file mode 100644 index 00000000..57899319 --- /dev/null +++ b/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/doc/审核集成功能整体方案计划.md @@ -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 sourceRecord; // 源单主表数据 + Map> sourceChildren; // 源单子表 + Map vars; // 运行时变量池 + Map 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 推进更新版本号与交付状态。* diff --git a/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/pom.xml b/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/pom.xml index 4b46e8ff..d6825359 100644 --- a/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/pom.xml +++ b/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/pom.xml @@ -19,13 +19,13 @@ jeecg-boot-base-core ${jeecgboot.version} - + com.dingtalk.open dingtalk-stream 1.3.12 - + org.jeecgframework.boot3 jeecg-system-biz diff --git a/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/callback/ApprovalCallbackContext.java b/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/callback/ApprovalCallbackContext.java index 5b810cd6..dba2379f 100644 --- a/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/callback/ApprovalCallbackContext.java +++ b/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/callback/ApprovalCallbackContext.java @@ -60,6 +60,16 @@ public class ApprovalCallbackContext implements Serializable { /** 当前/刚处理的节点名称 */ private String nodeName; + //update-begin---author:GHT ---date:20260605 for:【XSLMES-20260605-K8R3】新增stageKey区分关键环节节点与纯过路审批节点----------- + /** + * 节点绑定的审批环节(来自流程设计 props.stageKey)。 + * null = 节点未配置 stageKey(旧数据/手动添加),走降级启发式匹配。 + * "" = 节点显式设为「纯过路审批」,不触发任何集成动作。 + * 其他值 = 具体环节(proofread / audit / approve),直接作为集成方案匹配依据。 + */ + private String stageKey; + //update-end---author:GHT ---date:20260605 for:【XSLMES-20260605-K8R3】新增stageKey区分关键环节节点与纯过路审批节点----------- + /** 操作人 username(系统自动处理时为 null/system) */ private String operatorUsername; diff --git a/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/callback/ApprovalCallbackDispatcher.java b/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/callback/ApprovalCallbackDispatcher.java index 8e5b055c..c282a5cd 100644 --- a/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/callback/ApprovalCallbackDispatcher.java +++ b/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/callback/ApprovalCallbackDispatcher.java @@ -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> callbacksProvider; private final ApplicationEventPublisher eventPublisher; @@ -60,17 +63,40 @@ public class ApprovalCallbackDispatcher { private void dispatch(ApprovalCallbackContext ctx) { if (ctx == null || oConvertUtils.isEmpty(ctx.getBizTable())) { + if (isDingTalkCallback(ctx)) { + log.info("{} 分发跳过:ctx 或 bizTable 为空 action={}", DING_LOG_TAG, + ctx == null ? null : ctx.getAction()); + } return; } + //update-begin---author:GHT ---date:20260605 for:【XSLMES-20260605-K8R2】钉钉回调分发器全量日志----------- + List callbacks = matchedCallbacks(ctx.getBizTable()); + if (isDingTalkCallback(ctx)) { + log.info("{} 开始分发 action={} recordId={} bizTable={} bizDataId={} nodeId={} nodeName={} " + + "finalResult={} callbackCount={} comment={}", + DING_LOG_TAG, ctx.getAction(), ctx.getInstanceId(), ctx.getBizTable(), ctx.getBizDataId(), + ctx.getNodeId(), ctx.getNodeName(), ctx.isFinalResult(), callbacks.size(), ctx.getComment()); + } + //update-end---author:GHT ---date:20260605 for:【XSLMES-20260605-K8R2】钉钉回调分发器全量日志----------- // 1) 强类型回调:按表路由 + 通配 - for (IApprovalBizCallback cb : matchedCallbacks(ctx.getBizTable())) { + for (IApprovalBizCallback cb : callbacks) { invoke(cb, ctx); } // 2) 领域事件:松耦合监听(同步、同事务) try { eventPublisher.publishEvent(new ApprovalActionEvent(this, ctx)); + if (isDingTalkCallback(ctx)) { + log.info("{} 分发完成 action={} bizTable={} bizDataId={}", DING_LOG_TAG, + ctx.getAction(), ctx.getBizTable(), ctx.getBizDataId()); + } } catch (RuntimeException e) { - log.error("审批领域事件处理失败 table={}, bizId={}, action={}", ctx.getBizTable(), ctx.getBizDataId(), ctx.getAction(), e); + if (isDingTalkCallback(ctx)) { + log.error("{} 领域事件处理失败 action={} table={} bizId={}", DING_LOG_TAG, + ctx.getAction(), ctx.getBizTable(), ctx.getBizDataId(), e); + } else { + log.error("审批领域事件处理失败 table={}, bizId={}, action={}", + ctx.getBizTable(), ctx.getBizDataId(), ctx.getAction(), e); + } throw e; } } @@ -91,6 +117,12 @@ public class ApprovalCallbackDispatcher { } private void invoke(IApprovalBizCallback cb, ApprovalCallbackContext ctx) { + boolean dingTalk = isDingTalkCallback(ctx); + String callbackName = cb.getClass().getSimpleName(); + if (dingTalk) { + log.info("{} 执行业务回调 {} action={} bizTable={} bizDataId={}", + DING_LOG_TAG, callbackName, ctx.getAction(), ctx.getBizTable(), ctx.getBizDataId()); + } try { switch (ctx.getAction()) { case NODE_APPROVED: @@ -105,11 +137,26 @@ public class ApprovalCallbackDispatcher { default: break; } + if (dingTalk) { + log.info("{} 业务回调完成 {} action={} bizTable={} bizDataId={}", + DING_LOG_TAG, callbackName, ctx.getAction(), ctx.getBizTable(), ctx.getBizDataId()); + } } catch (RuntimeException e) { - log.error("审批业务回调执行失败 callback={}, table={}, bizId={}, action={}", - cb.getClass().getSimpleName(), ctx.getBizTable(), ctx.getBizDataId(), ctx.getAction(), e); + if (dingTalk) { + log.error("{} 业务回调失败 {} action={} table={} bizId={}", + DING_LOG_TAG, callbackName, ctx.getAction(), ctx.getBizTable(), ctx.getBizDataId(), e); + } else { + log.error("审批业务回调执行失败 callback={}, table={}, bizId={}, action={}", + callbackName, ctx.getBizTable(), ctx.getBizDataId(), ctx.getAction(), e); + } // 抛出以回滚整个审批动作,保证审批与业务数据一致 throw e; } } + + //update-begin---author:GHT ---date:20260605 for:【XSLMES-20260605-K8R2】识别钉钉Stream来源回调----------- + private boolean isDingTalkCallback(ApprovalCallbackContext ctx) { + return ctx != null && "dingtalk".equals(ctx.getOperatorUsername()); + } + //update-end---author:GHT ---date:20260605 for:【XSLMES-20260605-K8R2】识别钉钉Stream来源回调----------- } diff --git a/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/controller/MesXslApprovalFlowController.java b/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/controller/MesXslApprovalFlowController.java index 14358ef6..fa29d2e1 100644 --- a/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/controller/MesXslApprovalFlowController.java +++ b/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/controller/MesXslApprovalFlowController.java @@ -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 审核 -> 审批 -> 分发 -> 抄送。 - */ - 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>> 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 items = sysBaseAPI.getDictItems("mes_xsl_approval_biz_doc"); if (items != null) { for (DictModel item : items) { @@ -378,69 +389,39 @@ public class MesXslApprovalFlowController extends JeecgController> parseStageFields(String table) { + private List> parseRegistryStages(String table) { List> stages = new ArrayList<>(); - List> 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 hit = matchStageColumn(columns, keywords); - if (hit != null) { - Map 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 enabled = ApprovalStageResolver.parseEnabledStages(registry.getEnabledStages()); + String[][] ordered = new String[][]{ + {ApprovalStageResolver.STAGE_PROOFREAD, "校对", registry.getProofreadByField()}, + {ApprovalStageResolver.STAGE_AUDIT, "审核", registry.getAuditByField()}, + {ApprovalStageResolver.STAGE_APPROVE, "批准", registry.getApproveByField()}, + }; + for (String[] item : ordered) { + String stageKey = item[0]; + if (!enabled.contains(stageKey) || oConvertUtils.isEmpty(item[2])) { + continue; } + Map stage = new LinkedHashMap<>(); + stage.put("stageKey", stageKey); + stage.put("stageName", item[1]); + stage.put("nodeType", "approver"); + stage.put("field", item[2]); + stage.put("fieldComment", item[1] + "人"); + stages.add(stage); } return stages; } - - /** 在列集合中按关键字匹配阶段字段,优先返回注释含"人/员"的人员字段 */ - private Map matchStageColumn(List> columns, String[] keywords) { - Map firstMatch = null; - for (Map col : columns) { - String comment = col.get("comment") == null ? "" : String.valueOf(col.get("comment")); - if (oConvertUtils.isEmpty(comment)) { - continue; - } - boolean matched = false; - for (String kw : keywords) { - if (comment.contains(kw)) { - matched = true; - break; - } - } - if (!matched) { - continue; - } - if (firstMatch == null) { - firstMatch = col; - } - // 人员字段优先(如"校对人""审核员") - if (comment.contains("人") || comment.contains("员")) { - return col; - } - } - return firstMatch; - } + //update-end---author:GHT ---date:20260605 for:【XSLMES-20260605-K8R2】从审批注册中心解析启用环节----- /** 按业务表+租户查找审批流(取最近一条) */ private MesXslApprovalFlow findFlowByTable(String table, Integer tenantId) { diff --git a/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/entity/MesXslApprovalRecord.java b/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/entity/MesXslApprovalRecord.java index f5ab335f..5900f0b4 100644 --- a/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/entity/MesXslApprovalRecord.java +++ b/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/entity/MesXslApprovalRecord.java @@ -99,6 +99,15 @@ public class MesXslApprovalRecord extends JeecgEntity implements Serializable { @Schema(description = "备注") private String remark; + //update-begin---author:GHT ---date:2026-06-05 for:【审核集成Phase0】台账增加编排执行状态字段----- + @Schema(description = "编排执行状态 0未执行 1成功 2部分失败 3失败") + @Dict(dicCode = "mes_xsl_integration_orch_status") + private String integrationStatus; + + @Schema(description = "编排摘要/错误信息") + private String integrationRemark; + //update-end---author:GHT ---date:2026-06-05 for:【审核集成Phase0】台账增加编排执行状态字段----- + @Schema(description = "逻辑删除 0正常 1已删除") @TableLogic private Integer delFlag; diff --git a/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/integration/controller/MesXslApprovalTraceController.java b/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/integration/controller/MesXslApprovalTraceController.java new file mode 100644 index 00000000..c31fb969 --- /dev/null +++ b/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/integration/controller/MesXslApprovalTraceController.java @@ -0,0 +1,72 @@ +package org.jeecg.modules.xslmes.approval.integration.controller; + +import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper; +import com.baomidou.mybatisplus.core.metadata.IPage; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.servlet.http.HttpServletRequest; +import lombok.extern.slf4j.Slf4j; +import org.apache.shiro.authz.annotation.Logical; +import org.apache.shiro.authz.annotation.RequiresPermissions; +import org.jeecg.common.api.vo.Result; +import org.jeecg.common.system.base.controller.JeecgController; +import org.jeecg.common.system.query.QueryGenerator; +import org.jeecg.common.util.oConvertUtils; +import org.jeecg.modules.xslmes.approval.integration.entity.MesXslApprovalTrace; +import org.jeecg.modules.xslmes.approval.integration.service.IMesXslApprovalTraceService; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +/** + * 审批痕迹明细 + * + * @author GHT + * @date 2026-06-05 for:【XSLMES-20260605-K8R2】审批痕迹查询 + */ +@Tag(name = "审批痕迹") +@RestController +@RequestMapping("/xslmes/mesXslApprovalTrace") +@Slf4j +public class MesXslApprovalTraceController extends JeecgController { + + @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> queryPageList( + MesXslApprovalTrace model, + @RequestParam(defaultValue = "1") Integer pageNo, + @RequestParam(defaultValue = "10") Integer pageSize, + HttpServletRequest req) { + QueryWrapper qw = QueryGenerator.initQueryWrapper(model, req.getParameterMap()); + qw.orderByDesc("update_time").orderByDesc("create_time"); + return Result.OK(traceService.page(new Page<>(pageNo, pageSize), qw)); + } + + @Operation(summary = "审批痕迹-通过id查询") + @RequiresPermissions("xslmes:mes_xsl_approval_trace:list") + @GetMapping("/queryById") + public Result queryById(@RequestParam String id) { + MesXslApprovalTrace entity = traceService.getById(id); + return entity != null ? Result.OK(entity) : Result.error("未找到对应数据"); + } + + @Operation(summary = "审批痕迹-按业务表与单据ID查询(供业务页关联展示)") + @RequiresPermissions("xslmes:mes_xsl_approval_trace:list") + @GetMapping("/queryByBiz") + public Result 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); + } +} diff --git a/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/integration/controller/MesXslBizDocRegistryController.java b/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/integration/controller/MesXslBizDocRegistryController.java new file mode 100644 index 00000000..eafc6d86 --- /dev/null +++ b/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/integration/controller/MesXslBizDocRegistryController.java @@ -0,0 +1,84 @@ +package org.jeecg.modules.xslmes.approval.integration.controller; + +import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper; +import com.baomidou.mybatisplus.core.metadata.IPage; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.servlet.http.HttpServletRequest; +import lombok.extern.slf4j.Slf4j; +import org.apache.shiro.authz.annotation.RequiresPermissions; +import org.jeecg.common.api.vo.Result; +import org.jeecg.common.system.base.controller.JeecgController; +import org.jeecg.common.system.query.QueryGenerator; +import org.jeecg.modules.xslmes.approval.integration.entity.MesXslBizDocRegistry; +import org.jeecg.modules.xslmes.approval.integration.service.IMesXslBizDocRegistryService; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.web.bind.annotation.*; + +/** + * 审批注册中心 + * + * @author GHT + * @date 2026-06-05 for:【审核集成Phase0】单据注册 + */ +@Tag(name = "审批注册中心") +@RestController +@RequestMapping("/xslmes/mesXslBizDocRegistry") +@Slf4j +public class MesXslBizDocRegistryController extends JeecgController { + + @Autowired + private IMesXslBizDocRegistryService service; + + @Operation(summary = "审批注册-分页列表") + @RequiresPermissions("xslmes:mes_xsl_biz_doc_registry:list") + @GetMapping("/list") + public Result> queryPageList( + MesXslBizDocRegistry model, + @RequestParam(defaultValue = "1") Integer pageNo, + @RequestParam(defaultValue = "10") Integer pageSize, + HttpServletRequest req) { + QueryWrapper 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 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 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 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 queryById(@RequestParam String id) { + MesXslBizDocRegistry entity = service.getById(id); + return entity != null ? Result.OK(entity) : Result.error("未找到对应数据"); + } +} diff --git a/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/integration/controller/MesXslIntegrationLogController.java b/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/integration/controller/MesXslIntegrationLogController.java new file mode 100644 index 00000000..a834d832 --- /dev/null +++ b/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/integration/controller/MesXslIntegrationLogController.java @@ -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 { + + @Autowired + private IMesXslIntegrationLogService logService; + + @Operation(summary = "集成日志-分页列表") + @RequiresPermissions("xslmes:mes_xsl_integration_log:list") + @GetMapping("/list") + public Result> queryPageList( + MesXslIntegrationLog model, + @RequestParam(defaultValue = "1") Integer pageNo, + @RequestParam(defaultValue = "10") Integer pageSize, + HttpServletRequest req) { + QueryWrapper 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 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 retry(@RequestParam String id) { + return logService.retry(id); + } +} diff --git a/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/integration/controller/MesXslIntegrationPlanController.java b/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/integration/controller/MesXslIntegrationPlanController.java new file mode 100644 index 00000000..afe1d9b3 --- /dev/null +++ b/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/integration/controller/MesXslIntegrationPlanController.java @@ -0,0 +1,216 @@ +package org.jeecg.modules.xslmes.approval.integration.controller; + +import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper; +import com.baomidou.mybatisplus.core.metadata.IPage; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.servlet.http.HttpServletRequest; +import lombok.extern.slf4j.Slf4j; +import org.apache.shiro.authz.annotation.RequiresPermissions; +import org.jeecg.common.api.vo.Result; +import org.jeecg.common.system.base.controller.JeecgController; +import org.jeecg.common.system.query.QueryGenerator; +import org.jeecg.modules.xslmes.approval.integration.entity.MesXslBizDocRegistry; +import org.jeecg.modules.xslmes.approval.integration.entity.MesXslIntegrationAction; +import org.jeecg.modules.xslmes.approval.integration.entity.MesXslIntegrationPlan; +import org.jeecg.modules.xslmes.approval.integration.service.IMesXslBizDocRegistryService; +import org.jeecg.modules.xslmes.approval.integration.service.IMesXslIntegrationActionService; +import org.jeecg.modules.xslmes.approval.integration.service.IMesXslIntegrationPlanService; +import org.jeecg.modules.xslmes.approval.integration.service.IntegrationPlanGenerator; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.bind.annotation.*; + +import java.util.List; +import java.util.Map; + +/** + * 审核集成方案(含内嵌动作管理) + * + * @author GHT + * @date 2026-06-05 for:【审核集成Phase0】集成方案管理 + */ +@Tag(name = "审核集成方案") +@RestController +@RequestMapping("/xslmes/mesXslIntegrationPlan") +@Slf4j +public class MesXslIntegrationPlanController extends JeecgController { + + @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> queryPageList( + MesXslIntegrationPlan model, + @RequestParam(defaultValue = "1") Integer pageNo, + @RequestParam(defaultValue = "10") Integer pageSize, + HttpServletRequest req) { + QueryWrapper 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 add(@RequestBody MesXslIntegrationPlan entity) { + Result 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 edit(@RequestBody MesXslIntegrationPlan entity) { + Result 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 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 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 publish(@RequestParam String id) { + return planService.publish(id); + } + + @Operation(summary = "集成方案-停用") + @RequiresPermissions("xslmes:mes_xsl_integration_plan:publish") + @PostMapping("/disable") + public Result 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 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> 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> generateDefaultFromFlow(@RequestBody Map 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> nodeBindings = body != null + ? (List>) body.get("nodeBindings") : null; + return planGenerator.generate(sourceTable, flowId, overwriteDraft, + IntegrationPlanGenerator.parseStageOverrides(nodeBindings)); + //update-end---author:GHT ---date:20260605 for:【XSLMES-20260605-K8R2】生成时支持手选识别环节----------- + } + //update-end---author:GHT ---date:20260605 for:【XSLMES-20260605-K8R2】按审批流程节点生成默认集成方案----------- + + //update-begin---author:GHT ---date:2026-06-05 for:【审核集成Phase0】新增表字段元数据查询接口(可视化配置向导用)----------- + @Operation(summary = "查询表字段元数据(可视化配置向导)") + @RequiresPermissions("xslmes:mes_xsl_integration_plan:list") + @GetMapping("/tableColumns") + public Result>> getTableColumns(@RequestParam String tableName) { + if (!tableName.matches("^[a-z][a-z0-9_]{0,63}$")) { + return Result.error("非法表名"); + } + List> 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> listActions(@RequestParam String planId) { + return Result.OK(actionService.listByPlanId(planId)); + } + + @Operation(summary = "动作-新增") + @RequiresPermissions("xslmes:mes_xsl_integration_plan:edit") + @PostMapping("/action/add") + public Result addAction(@RequestBody MesXslIntegrationAction action) { + actionService.save(action); + return Result.OK("添加成功"); + } + + @Operation(summary = "动作-编辑") + @RequiresPermissions("xslmes:mes_xsl_integration_plan:edit") + @PutMapping("/action/edit") + public Result editAction(@RequestBody MesXslIntegrationAction action) { + actionService.updateById(action); + return Result.OK("编辑成功"); + } + + @Operation(summary = "动作-删除") + @RequiresPermissions("xslmes:mes_xsl_integration_plan:edit") + @DeleteMapping("/action/delete") + public Result deleteAction(@RequestParam String id) { + actionService.removeById(id); + return Result.OK("删除成功"); + } +} diff --git a/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/integration/engine/ApprovalStageResolver.java b/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/integration/engine/ApprovalStageResolver.java new file mode 100644 index 00000000..f5ee78ca --- /dev/null +++ b/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/integration/engine/ApprovalStageResolver.java @@ -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 parseEnabledStages(String enabledStages) { + if (oConvertUtils.isEmpty(enabledStages)) { + return Collections.emptySet(); + } + Set 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); + } + + /** + * 根据回调上下文与注册配置,解析当前刚完成的审批环节。 + *

+ * 优先级: + * 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 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 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; + } +} diff --git a/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/integration/engine/IntegrationBizCallback.java b/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/integration/engine/IntegrationBizCallback.java new file mode 100644 index 00000000..84592511 --- /dev/null +++ b/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/integration/engine/IntegrationBizCallback.java @@ -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。 + *

+ * 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】钉钉回调集成编排入口日志----------- +} diff --git a/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/integration/engine/IntegrationContext.java b/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/integration/engine/IntegrationContext.java new file mode 100644 index 00000000..a492eb82 --- /dev/null +++ b/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/integration/engine/IntegrationContext.java @@ -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 sourceRecord = new HashMap<>(); + + /** 前序动作输出结果(actionId → 产出值) */ + private Map actionResults = new HashMap<>(); +} diff --git a/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/integration/engine/IntegrationOrchestrator.java b/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/integration/engine/IntegrationOrchestrator.java new file mode 100644 index 00000000..bdb092f5 --- /dev/null +++ b/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/integration/engine/IntegrationOrchestrator.java @@ -0,0 +1,515 @@ +package org.jeecg.modules.xslmes.approval.integration.engine; + +import com.alibaba.fastjson2.JSON; +import com.alibaba.fastjson2.JSONObject; +import lombok.extern.slf4j.Slf4j; +import org.jeecg.common.util.oConvertUtils; +import org.jeecg.modules.xslmes.approval.callback.ApprovalCallbackContext; +import org.jeecg.modules.xslmes.approval.entity.MesXslApprovalRecord; +import org.jeecg.modules.xslmes.approval.integration.engine.executor.IIntegrationActionExecutor; +import org.jeecg.modules.xslmes.approval.integration.entity.MesXslBizDocRegistry; +import org.jeecg.modules.xslmes.approval.integration.entity.MesXslIntegrationAction; +import org.jeecg.modules.xslmes.approval.integration.entity.MesXslIntegrationLog; +import org.jeecg.modules.xslmes.approval.integration.entity.MesXslIntegrationPlan; +import org.jeecg.modules.xslmes.approval.integration.service.IMesXslBizDocRegistryService; +import org.jeecg.modules.xslmes.approval.integration.service.IMesXslIntegrationActionService; +import org.jeecg.modules.xslmes.approval.integration.service.IMesXslIntegrationLogService; +import org.jeecg.modules.xslmes.approval.integration.service.IMesXslIntegrationPlanService; +import org.jeecg.modules.xslmes.approval.service.IMesXslApprovalRecordService; +import org.jeecg.modules.xslmes.dingtalk.stream.DingTalkStreamSdkRunner; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.transaction.support.TransactionSynchronization; +import org.springframework.transaction.support.TransactionSynchronizationManager; + +import java.util.Date; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +/** + * 审核集成编排引擎。 + *

+ * 执行流程: + *

    + *
  1. 根据 (source_table, trigger_phase) 查找已发布方案
  2. + *
  3. 加载源单主表字段到 IntegrationContext
  4. + *
  5. 按 exec_order 依次执行动作,幂等检查、写日志
  6. + *
  7. 更新审批台账 integration_status
  8. + *
+ * 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 executors; + + // ==================== 外部入口 ==================== + + /** + * 由 IntegrationBizCallback 在审批回调时调用。 + * 自动按 exec_mode 决定同步还是异步执行。 + */ + public void dispatch(ApprovalCallbackContext approvalCtx, TriggerPhase phase) { + String bizTable = approvalCtx.getBizTable(); + String bizDataId = approvalCtx.getBizDataId(); + if (oConvertUtils.isEmpty(bizTable) || oConvertUtils.isEmpty(bizDataId)) { + return; + } + + List 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 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 actions = actionService.listByPlanId(plan.getId()); + if (actions.isEmpty()) { + log.info("[集成引擎] 方案 {} 无启用动作,跳过", plan.getPlanCode()); + return; + } + + int successCount = 0; + int failCount = 0; + StringBuilder remarkBuf = new StringBuilder(); + + for (MesXslIntegrationAction action : actions) { + String idempotentKey = buildIdempotentKey(ctx, action); + String snapshot = JSON.toJSONString(Map.of( + "sourceId", ctx.getSourceBizId(), + "sourceTable", ctx.getSourceBizTable(), + "phase", ctx.getTriggerPhase().getValue())); + + //update-begin---author:GHT ---date:20260605 for:【XSLMES-20260605-K8R2】多轮审批幂等:按台账隔离+回退未达目标强制重跑----------- + // 幂等检查(REGISTRY_STAGE_REVERT 若源单仍未回到目标状态,忽略历史 success 重新执行) + if (logService.isAlreadySuccess(idempotentKey) && !shouldBypassIdempotentSkip(ctx, action)) { + log.info("[集成引擎] 幂等命中,跳过 action={} key={}", action.getActionName(), idempotentKey); + writeLog(ctx, action, idempotentKey, "skipped", null, null, snapshot, null, 0L); + continue; + } + if (logService.isAlreadySuccess(idempotentKey)) { + log.info("[集成引擎] 幂等命中但源单未达目标,重新执行 action={} key={}", action.getActionName(), idempotentKey); + } + //update-end---author:GHT ---date:20260605 for:【XSLMES-20260605-K8R2】多轮审批幂等:按台账隔离+回退未达目标强制重跑----------- + + long t0 = System.currentTimeMillis(); + try { + IIntegrationActionExecutor executor = findExecutor(action.getActionType()); + String response = executor.execute(ctx, action); + long ms = System.currentTimeMillis() - t0; + writeLog(ctx, action, idempotentKey, "success", null, response, snapshot, null, ms); + successCount++; + } catch (Exception e) { + long ms = System.currentTimeMillis() - t0; + String errMsg = e.getMessage(); + log.error("[集成引擎] 动作执行失败 action={} plan={}", action.getActionName(), plan.getPlanCode(), e); + writeLog(ctx, action, idempotentKey, "failed", errMsg, null, snapshot, null, ms); + failCount++; + remarkBuf.append("[").append(action.getActionName()).append("]").append(errMsg).append("; "); + if ("stop".equals(action.getOnFail())) { + break; + } + } + } + + // 更新台账 integration_status + if (record != null && oConvertUtils.isNotEmpty(record.getId())) { + String orchStatus; + if (failCount == 0) { + orchStatus = "1"; // 全部成功 + } else if (successCount > 0) { + orchStatus = "2"; // 部分失败 + } else { + orchStatus = "3"; // 全部失败 + } + recordService.lambdaUpdate() + .eq(MesXslApprovalRecord::getId, record.getId()) + .set(MesXslApprovalRecord::getIntegrationStatus, orchStatus) + .set(MesXslApprovalRecord::getIntegrationRemark, + remarkBuf.length() > 0 ? remarkBuf.toString() : null) + .update(); + } + } + + // ==================== 工具方法 ==================== + + private IntegrationContext buildContext(MesXslIntegrationPlan plan, + ApprovalCallbackContext approvalCtx, + MesXslApprovalRecord record) { + String bizTable = approvalCtx.getBizTable(); + String bizDataId = approvalCtx.getBizDataId(); + TriggerPhase phase = switch (plan.getTriggerPhase()) { + case "onReject" -> TriggerPhase.ON_REJECT; + case "onNodeApprove" -> TriggerPhase.ON_NODE_APPROVE; + default -> TriggerPhase.ON_APPROVE; + }; + + IntegrationContext ctx = new IntegrationContext() + .setApprovalCtx(approvalCtx) + .setRecord(record) + .setPlan(plan) + .setSourceBizTable(bizTable) + .setSourceBizId(bizDataId) + .setTriggerPhase(phase); + + Map sourceRecord = loadSourceRecord(bizTable, bizDataId); + if (sourceRecord != null) { + ctx.setSourceRecord(sourceRecord); + } + return ctx; + } + + private Map loadSourceRecord(String bizTable, String bizDataId) { + try { + List> rows = jdbcTemplate.queryForList( + "SELECT * FROM `" + bizTable + "` WHERE id = ?", bizDataId); + if (!rows.isEmpty()) { + return rows.get(0); + } + } catch (Exception e) { + log.warn("[集成引擎] 加载源单字段失败 table={} id={}: {}", bizTable, bizDataId, e.getMessage()); + } + return null; + } + + //update-begin---author:GHT ---date:20260605 for:【XSLMES-20260605-K8R2】修复台账查找:兼容钉钉recordId与MES外部实例ID----------- + private MesXslApprovalRecord findRecord(ApprovalCallbackContext approvalCtx) { + try { + String instanceId = approvalCtx.getInstanceId(); + if (oConvertUtils.isNotEmpty(instanceId)) { + // MES 通道:instanceId = 审批实例ID,对应台账 external_instance_id + MesXslApprovalRecord byExternal = recordService.lambdaQuery() + .eq(MesXslApprovalRecord::getExternalInstanceId, instanceId) + .orderByDesc(MesXslApprovalRecord::getCreateTime) + .last("LIMIT 1") + .one(); + if (byExternal != null) { + return byExternal; + } + // 钉钉 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 targetStage = "compile"; + if (oConvertUtils.isNotEmpty(action.getActionConfig())) { + try { + JSONObject cfg = JSONObject.parseObject(action.getActionConfig()); + if (oConvertUtils.isNotEmpty(cfg.getString("targetStage"))) { + targetStage = cfg.getString("targetStage").trim(); + } + } catch (Exception ignored) { + // 默认 compile + } + } + return targetStage; + } + + private String readSourceStatus(IntegrationContext ctx) { + Map 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 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 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 actions = actionService.listByPlanId(plan.getId()); + if (actions == null || actions.isEmpty()) { + return false; + } + String expectedFrom = resolveExpectedFromFromAction(actions.get(0), plan.getTriggerStage()); + if (oConvertUtils.isEmpty(expectedFrom)) { + return false; + } + return expectedFrom.equals(currentStatus); + } + + private String resolveExpectedFromFromAction(MesXslIntegrationAction action, String triggerStage) { + if (action != null && oConvertUtils.isNotEmpty(action.getActionConfig())) { + try { + JSONObject cfg = JSONObject.parseObject(action.getActionConfig()); + JSONObject registryStage = cfg.getJSONObject("registryStage"); + if (registryStage != null && oConvertUtils.isNotEmpty(registryStage.getString("expectedFrom"))) { + return registryStage.getString("expectedFrom").trim(); + } + if (cfg.containsKey("expectedFrom")) { + String v = cfg.getString("expectedFrom"); + return oConvertUtils.isEmpty(v) ? null : v.trim(); + } + } catch (Exception ignored) { + // fallback + } + } + return RegistryStageFieldHelper.defaultExpectedFrom(triggerStage); + } + //update-end---author:GHT ---date:20260605 for:【XSLMES-20260605-K8R2】集成方案环节匹配增强(节点名+源单status)----------- + + //update-begin---author:GHT ---date:20260605 for:【XSLMES-20260605-K8R2】识别钉钉Stream来源回调----------- + private boolean isDingTalkCallback(ApprovalCallbackContext ctx) { + return ctx != null && "dingtalk".equals(ctx.getOperatorUsername()); + } + //update-end---author:GHT ---date:20260605 for:【XSLMES-20260605-K8R2】识别钉钉Stream来源回调----------- +} diff --git a/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/integration/engine/RegistryStageFieldHelper.java b/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/integration/engine/RegistryStageFieldHelper.java new file mode 100644 index 00000000..e5d30b78 --- /dev/null +++ b/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/integration/engine/RegistryStageFieldHelper.java @@ -0,0 +1,69 @@ +package org.jeecg.modules.xslmes.approval.integration.engine; + +import org.jeecg.common.util.oConvertUtils; +import org.jeecg.modules.xslmes.approval.integration.entity.MesXslBizDocRegistry; + +/** + * 审批注册中心环节与业务表字段映射辅助 + */ +public final class RegistryStageFieldHelper { + + private RegistryStageFieldHelper() { + } + + public static String statusField(MesXslBizDocRegistry registry) { + return oConvertUtils.isEmpty(registry.getStatusField()) ? "status" : registry.getStatusField(); + } + + public static String byField(MesXslBizDocRegistry registry, String stage) { + if (registry == null || oConvertUtils.isEmpty(stage)) { + return null; + } + switch (stage) { + case ApprovalStageResolver.STAGE_PROOFREAD: + return registry.getProofreadByField(); + case ApprovalStageResolver.STAGE_AUDIT: + return registry.getAuditByField(); + case ApprovalStageResolver.STAGE_APPROVE: + return registry.getApproveByField(); + default: + return null; + } + } + + public static String timeField(MesXslBizDocRegistry registry, String stage) { + if (registry == null || oConvertUtils.isEmpty(stage)) { + return null; + } + switch (stage) { + case ApprovalStageResolver.STAGE_PROOFREAD: + return registry.getProofreadTimeField(); + case ApprovalStageResolver.STAGE_AUDIT: + return registry.getAuditTimeField(); + case ApprovalStageResolver.STAGE_APPROVE: + return registry.getApproveTimeField(); + default: + return null; + } + } + + /** 环节默认前置状态:proofread←compile, audit←proofread, approve←audit */ + public static String defaultExpectedFrom(String stage) { + switch (stage) { + case ApprovalStageResolver.STAGE_PROOFREAD: + return "compile"; + case ApprovalStageResolver.STAGE_AUDIT: + return ApprovalStageResolver.STAGE_PROOFREAD; + case ApprovalStageResolver.STAGE_APPROVE: + return ApprovalStageResolver.STAGE_AUDIT; + default: + return null; + } + } + + public static void assertIdentifier(String name) { + if (oConvertUtils.isEmpty(name) || !name.matches("^[a-z][a-z0-9_]{0,63}$")) { + throw new IllegalArgumentException("非法字段名: " + name); + } + } +} diff --git a/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/integration/engine/TriggerPhase.java b/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/integration/engine/TriggerPhase.java new file mode 100644 index 00000000..d7ddd33a --- /dev/null +++ b/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/integration/engine/TriggerPhase.java @@ -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; + } +} diff --git a/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/integration/engine/VariableResolver.java b/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/integration/engine/VariableResolver.java new file mode 100644 index 00000000..60622fdc --- /dev/null +++ b/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/integration/engine/VariableResolver.java @@ -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 模板、幂等键等场景。 + *

+ * 支持的变量: + *

    + *
  • #{source.id} / #{id} — 源单 ID
  • + *
  • #{source.字段名} — 源单主表字段
  • + *
  • #{sys_user_code} — 当前操作人 username
  • + *
  • #{sys_date} — 当前日期 yyyy-MM-dd
  • + *
  • #{sys_datetime} — 当前时间 yyyy-MM-dd HH:mm:ss
  • + *
  • #{approval.instance_id} — 审批实例 ID
  • + *
  • #{approval.apply_user} — 审批发起人
  • + *
+ */ +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 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; + } +} diff --git a/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/integration/engine/executor/IIntegrationActionExecutor.java b/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/integration/engine/executor/IIntegrationActionExecutor.java new file mode 100644 index 00000000..0bd23f3c --- /dev/null +++ b/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/integration/engine/executor/IIntegrationActionExecutor.java @@ -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); +} diff --git a/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/integration/engine/executor/RegistryStageRevertExecutor.java b/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/integration/engine/executor/RegistryStageRevertExecutor.java new file mode 100644 index 00000000..b94154cd --- /dev/null +++ b/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/integration/engine/executor/RegistryStageRevertExecutor.java @@ -0,0 +1,105 @@ +package org.jeecg.modules.xslmes.approval.integration.engine.executor; + +import com.alibaba.fastjson2.JSONObject; +import lombok.extern.slf4j.Slf4j; +import org.jeecg.common.util.oConvertUtils; +import org.jeecg.modules.xslmes.approval.integration.engine.IntegrationContext; +import org.jeecg.modules.xslmes.approval.integration.engine.RegistryStageFieldHelper; +import org.jeecg.modules.xslmes.approval.integration.entity.MesXslBizDocRegistry; +import org.jeecg.modules.xslmes.approval.integration.entity.MesXslIntegrationAction; +import org.jeecg.modules.xslmes.approval.integration.service.IApprovalTraceSyncService; +import org.jeecg.modules.xslmes.approval.integration.service.IMesXslBizDocRegistryService; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Component; + +/** + * 审批驳回回退:按注册中心配置将源单 status 回退并清空环节痕迹(默认回 compile)。 + */ +@Slf4j +@Component +public class RegistryStageRevertExecutor implements IIntegrationActionExecutor { + + @Autowired + private IMesXslBizDocRegistryService registryService; + @Autowired + private IApprovalTraceSyncService approvalTraceSyncService; + @Autowired + private JdbcTemplate jdbcTemplate; + + @Override + public String supportActionType() { + return "REGISTRY_STAGE_REVERT"; + } + + //update-begin---author:GHT ---date:20260605 for:【XSLMES-20260605-K8R2】审批注册中心环节回退执行器----------- + @Override + public String execute(IntegrationContext ctx, MesXslIntegrationAction action) { + String bizTable = ctx.getSourceBizTable(); + String bizId = ctx.getSourceBizId(); + if (oConvertUtils.isEmpty(bizTable) || oConvertUtils.isEmpty(bizId)) { + throw new IllegalArgumentException("缺少源单表名或ID"); + } + + MesXslBizDocRegistry registry = registryService.findActiveByTableName(bizTable); + if (registry == null) { + throw new IllegalStateException("业务表未在审批注册中心启用: " + bizTable); + } + + String targetStage = "compile"; + if (oConvertUtils.isNotEmpty(action.getActionConfig())) { + try { + JSONObject cfg = JSONObject.parseObject(action.getActionConfig()); + if (oConvertUtils.isNotEmpty(cfg.getString("targetStage"))) { + targetStage = cfg.getString("targetStage").trim(); + } + } catch (Exception ignored) { + // 使用默认 compile + } + } + + String statusField = RegistryStageFieldHelper.statusField(registry); + RegistryStageFieldHelper.assertIdentifier(statusField); + RegistryStageFieldHelper.assertIdentifier(bizTable); + + StringBuilder sql = new StringBuilder("UPDATE `").append(bizTable).append("` SET `") + .append(statusField).append("`=?"); + java.util.List params = new java.util.ArrayList<>(); + params.add(targetStage); + + clearField(sql, params, registry.getProofreadByField()); + clearField(sql, params, registry.getProofreadTimeField()); + clearField(sql, params, registry.getAuditByField()); + clearField(sql, params, registry.getAuditTimeField()); + clearField(sql, params, registry.getApproveByField()); + clearField(sql, params, registry.getApproveTimeField()); + + if ("compile".equals(targetStage)) { + // 已全部清空 + } else if ("proofread".equals(targetStage)) { + // 保留 proofread,清空 audit/approve — 上面已全清,需按目标环节保留(简化:compile 场景为主) + } + + sql.append(" WHERE id=?"); + params.add(bizId); + + int affected = jdbcTemplate.update(sql.toString(), params.toArray()); + if (affected == 0) { + throw new IllegalStateException("源单不存在或回退失败 id=" + bizId); + } + + approvalTraceSyncService.revertToStage(bizTable, bizId, targetStage); + log.info("[集成引擎][REGISTRY_STAGE_REVERT] table={} id={} targetStage={}", bizTable, bizId, targetStage); + return "环节回退成功: " + targetStage; + } + + private void clearField(StringBuilder sql, java.util.List params, String field) { + if (oConvertUtils.isEmpty(field)) { + return; + } + RegistryStageFieldHelper.assertIdentifier(field); + sql.append(", `").append(field).append("`=?"); + params.add(null); + } + //update-end---author:GHT ---date:20260605 for:【XSLMES-20260605-K8R2】审批注册中心环节回退执行器----------- +} diff --git a/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/integration/engine/executor/RegistryStageSyncExecutor.java b/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/integration/engine/executor/RegistryStageSyncExecutor.java new file mode 100644 index 00000000..fdcfc5e6 --- /dev/null +++ b/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/integration/engine/executor/RegistryStageSyncExecutor.java @@ -0,0 +1,155 @@ +package org.jeecg.modules.xslmes.approval.integration.engine.executor; + +import com.alibaba.fastjson2.JSONObject; +import java.util.Date; +import lombok.extern.slf4j.Slf4j; +import org.jeecg.common.util.oConvertUtils; +import org.jeecg.modules.xslmes.approval.callback.ApprovalCallbackContext; +import org.jeecg.modules.xslmes.approval.integration.engine.ApprovalStageResolver; +import org.jeecg.modules.xslmes.approval.integration.engine.IntegrationContext; +import org.jeecg.modules.xslmes.approval.integration.engine.RegistryStageFieldHelper; +import org.jeecg.modules.xslmes.approval.integration.entity.MesXslBizDocRegistry; +import org.jeecg.modules.xslmes.approval.integration.entity.MesXslIntegrationAction; +import org.jeecg.modules.xslmes.approval.integration.entity.MesXslIntegrationPlan; +import org.jeecg.modules.xslmes.approval.integration.service.IApprovalTraceSyncService; +import org.jeecg.modules.xslmes.approval.integration.service.IMesXslBizDocRegistryService; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Component; + +/** + * 审批注册中心环节同步:无需手写 SQL,按注册配置更新源单 status/操作人/时间,并双写审批痕迹。 + */ +@Slf4j +@Component +public class RegistryStageSyncExecutor implements IIntegrationActionExecutor { + + @Autowired + private IMesXslBizDocRegistryService registryService; + @Autowired + private IApprovalTraceSyncService approvalTraceSyncService; + @Autowired + private JdbcTemplate jdbcTemplate; + + @Override + public String supportActionType() { + return "REGISTRY_STAGE_SYNC"; + } + + //update-begin---author:GHT ---date:20260605 for:【XSLMES-20260605-K8R2】审批注册中心环节同步执行器----------- + @Override + public String execute(IntegrationContext ctx, MesXslIntegrationAction action) { + String bizTable = ctx.getSourceBizTable(); + String bizId = ctx.getSourceBizId(); + if (oConvertUtils.isEmpty(bizTable) || oConvertUtils.isEmpty(bizId)) { + throw new IllegalArgumentException("缺少源单表名或ID"); + } + + MesXslBizDocRegistry registry = registryService.findActiveByTableName(bizTable); + if (registry == null) { + throw new IllegalStateException("业务表未在审批注册中心启用: " + bizTable); + } + + String stage = resolveStage(ctx, action); + String stageErr = approvalTraceSyncService.checkStageAllowed(bizTable, stage); + if (stageErr != null) { + throw new IllegalStateException(stageErr); + } + + String expectedFrom = resolveExpectedFrom(action, stage); + String statusField = RegistryStageFieldHelper.statusField(registry); + String byField = RegistryStageFieldHelper.byField(registry, stage); + String timeField = RegistryStageFieldHelper.timeField(registry, stage); + RegistryStageFieldHelper.assertIdentifier(statusField); + if (oConvertUtils.isNotEmpty(byField)) { + RegistryStageFieldHelper.assertIdentifier(byField); + } + if (oConvertUtils.isNotEmpty(timeField)) { + RegistryStageFieldHelper.assertIdentifier(timeField); + } + + String operator = resolveOperator(ctx); + Date now = new Date(); + + if (oConvertUtils.isNotEmpty(expectedFrom)) { + Object current = jdbcTemplate.queryForObject( + "SELECT `" + statusField + "` FROM `" + bizTable + "` WHERE id = ?", + Object.class, bizId); + String currentStr = current == null ? "" : String.valueOf(current).trim(); + if (!expectedFrom.equals(currentStr)) { + return "跳过:当前状态=" + currentStr + ",期望=" + expectedFrom; + } + } + + StringBuilder sql = new StringBuilder("UPDATE `").append(bizTable).append("` SET `") + .append(statusField).append("`=?"); + java.util.List params = new java.util.ArrayList<>(); + params.add(stage); + if (oConvertUtils.isNotEmpty(byField)) { + sql.append(", `").append(byField).append("`=?"); + params.add(operator); + } + if (oConvertUtils.isNotEmpty(timeField)) { + sql.append(", `").append(timeField).append("`=?"); + params.add(now); + } + sql.append(" WHERE id=?"); + params.add(bizId); + + int affected = jdbcTemplate.update(sql.toString(), params.toArray()); + if (affected == 0) { + throw new IllegalStateException("源单不存在或更新失败 id=" + bizId); + } + + approvalTraceSyncService.syncStage(bizTable, bizId, stage, operator, now); + log.info("[集成引擎][REGISTRY_STAGE_SYNC] table={} id={} stage={} operator={}", + bizTable, bizId, stage, operator); + return "环节同步成功: " + ApprovalStageResolver.stageLabel(stage); + } + + private String resolveStage(IntegrationContext ctx, MesXslIntegrationAction action) { + if (oConvertUtils.isNotEmpty(action.getActionConfig())) { + try { + JSONObject cfg = JSONObject.parseObject(action.getActionConfig()); + String stage = cfg.getString("stage"); + if (oConvertUtils.isNotEmpty(stage)) { + return stage.trim(); + } + } catch (Exception ignored) { + // 继续 fallback + } + } + MesXslIntegrationPlan plan = ctx.getPlan(); + if (plan != null && oConvertUtils.isNotEmpty(plan.getTriggerStage())) { + return plan.getTriggerStage(); + } + throw new IllegalArgumentException("动作未配置审批环节(stage),且方案未绑定 triggerStage"); + } + + private String resolveExpectedFrom(MesXslIntegrationAction action, String stage) { + if (oConvertUtils.isNotEmpty(action.getActionConfig())) { + try { + JSONObject cfg = JSONObject.parseObject(action.getActionConfig()); + if (cfg.containsKey("expectedFrom")) { + String v = cfg.getString("expectedFrom"); + return oConvertUtils.isEmpty(v) ? null : v.trim(); + } + } catch (Exception ignored) { + // fallback + } + } + return RegistryStageFieldHelper.defaultExpectedFrom(stage); + } + + private String resolveOperator(IntegrationContext ctx) { + ApprovalCallbackContext ac = ctx.getApprovalCtx(); + if (ac != null && oConvertUtils.isNotEmpty(ac.getOperatorName())) { + return ac.getOperatorName(); + } + if (ac != null && oConvertUtils.isNotEmpty(ac.getOperatorUsername())) { + return ac.getOperatorUsername(); + } + return "系统"; + } + //update-end---author:GHT ---date:20260605 for:【XSLMES-20260605-K8R2】审批注册中心环节同步执行器----------- +} diff --git a/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/integration/engine/executor/SqlUpdateActionExecutor.java b/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/integration/engine/executor/SqlUpdateActionExecutor.java new file mode 100644 index 00000000..29859b07 --- /dev/null +++ b/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/integration/engine/executor/SqlUpdateActionExecutor.java @@ -0,0 +1,70 @@ +package org.jeecg.modules.xslmes.approval.integration.engine.executor; + +import lombok.extern.slf4j.Slf4j; +import org.jeecg.common.util.oConvertUtils; +import org.jeecg.modules.xslmes.approval.integration.engine.IntegrationContext; +import org.jeecg.modules.xslmes.approval.integration.engine.VariableResolver; +import org.jeecg.modules.xslmes.approval.integration.entity.MesXslIntegrationAction; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Component; + +import java.util.regex.Pattern; + +/** + * SQL_UPDATE 动作执行器。 + * 支持 UPDATE / INSERT 语句,变量用 #{...} 占位。 + * 安全约束: + * 1. SQL 必须以 UPDATE 或 INSERT 开头(不区分大小写) + * 2. 禁止含 DROP / TRUNCATE / DELETE(无 WHERE 条件的批量删除风险) + * 3. 变量值经过 SQL 字面量转义 + * + * @author GHT + * @date 2026-06-05 for:【审核集成Phase0】SQL_UPDATE执行器 + */ +@Slf4j +@Component +public class SqlUpdateActionExecutor implements IIntegrationActionExecutor { + + private static final Pattern ALLOWED_START = Pattern.compile("^(UPDATE|INSERT)\\b", Pattern.CASE_INSENSITIVE); + private static final Pattern DANGEROUS = Pattern.compile("\\b(DROP|TRUNCATE|DELETE\\s+FROM)\\b", Pattern.CASE_INSENSITIVE); + + @Autowired + private JdbcTemplate jdbcTemplate; + + @Override + public String supportActionType() { + return "SQL_UPDATE"; + } + + @Override + public String execute(IntegrationContext ctx, MesXslIntegrationAction action) { + String template = action.getSqlTemplate(); + if (oConvertUtils.isEmpty(template)) { + throw new IllegalArgumentException("动作 [" + action.getActionName() + "] sql_template 为空"); + } + + // 变量替换 + String resolvedSql = VariableResolver.resolveSql(template.trim(), ctx); + + // 安全校验 + validate(resolvedSql, action.getActionName()); + + log.info("[集成引擎][SQL_UPDATE] 执行 action={} sql={}", action.getActionName(), resolvedSql); + int affected = jdbcTemplate.update(resolvedSql); + String result = "影响行数: " + affected; + log.info("[集成引擎][SQL_UPDATE] 完成 action={} {}", action.getActionName(), result); + return result; + } + + private void validate(String sql, String actionName) { + if (!ALLOWED_START.matcher(sql).find()) { + throw new IllegalArgumentException( + "集成动作 [" + actionName + "] SQL 必须以 UPDATE 或 INSERT 开头,实际: " + sql.substring(0, Math.min(50, sql.length()))); + } + if (DANGEROUS.matcher(sql).find()) { + throw new IllegalArgumentException( + "集成动作 [" + actionName + "] SQL 含有危险关键字(DROP/TRUNCATE/DELETE FROM),已拒绝执行"); + } + } +} diff --git a/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/integration/entity/MesXslApprovalTrace.java b/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/integration/entity/MesXslApprovalTrace.java new file mode 100644 index 00000000..f2ba820d --- /dev/null +++ b/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/integration/entity/MesXslApprovalTrace.java @@ -0,0 +1,76 @@ +package org.jeecg.modules.xslmes.approval.integration.entity; + +import com.baomidou.mybatisplus.annotation.TableLogic; +import com.baomidou.mybatisplus.annotation.TableName; +import com.fasterxml.jackson.annotation.JsonFormat; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.experimental.Accessors; +import org.jeecg.common.system.base.entity.JeecgEntity; +import org.springframework.format.annotation.DateTimeFormat; + +import java.io.Serializable; +import java.util.Date; + +/** + * 审批痕迹明细(每业务单据一行) + * + * @author GHT + * @date 2026-06-05 for:【XSLMES-20260605-K8R2】审批痕迹明细 + */ +@Data +@EqualsAndHashCode(callSuper = false) +@Accessors(chain = true) +@TableName("mes_xsl_approval_trace") +@Schema(description = "审批痕迹明细") +public class MesXslApprovalTrace extends JeecgEntity implements Serializable { + + private static final long serialVersionUID = 1L; + + @Schema(description = "审批注册配置ID") + private String registryId; + + @Schema(description = "业务表名") + private String bizTable; + + @Schema(description = "业务单据ID") + private String bizDataId; + + @Schema(description = "校对人") + private String proofreadBy; + + @JsonFormat(timezone = "GMT+8", pattern = "yyyy-MM-dd HH:mm:ss") + @DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss") + @Schema(description = "校对时间") + private Date proofreadTime; + + @Schema(description = "审核人") + private String auditBy; + + @JsonFormat(timezone = "GMT+8", pattern = "yyyy-MM-dd HH:mm:ss") + @DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss") + @Schema(description = "审核时间") + private Date auditTime; + + @Schema(description = "批准人") + private String approveBy; + + @JsonFormat(timezone = "GMT+8", pattern = "yyyy-MM-dd HH:mm:ss") + @DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss") + @Schema(description = "批准时间") + private Date approveTime; + + @Schema(description = "备注") + private String remark; + + @Schema(description = "逻辑删除 0正常 1已删除") + @TableLogic + private Integer delFlag; + + @Schema(description = "租户ID") + private Integer tenantId; + + @Schema(description = "所属部门编码") + private String sysOrgCode; +} diff --git a/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/integration/entity/MesXslBizDocRegistry.java b/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/integration/entity/MesXslBizDocRegistry.java new file mode 100644 index 00000000..927b503a --- /dev/null +++ b/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/integration/entity/MesXslBizDocRegistry.java @@ -0,0 +1,84 @@ +package org.jeecg.modules.xslmes.approval.integration.entity; + +import com.baomidou.mybatisplus.annotation.FieldStrategy; +import com.baomidou.mybatisplus.annotation.TableField; +import com.baomidou.mybatisplus.annotation.TableLogic; +import com.baomidou.mybatisplus.annotation.TableName; +import io.swagger.v3.oas.annotations.media.Schema; +import org.jeecg.common.aspect.annotation.Dict; +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.experimental.Accessors; +import org.jeecg.common.system.base.entity.JeecgEntity; + +import java.io.Serializable; + +/** + * 审批注册中心 + * + * @author GHT + * @date 2026-06-05 for:【审核集成Phase0】单据注册 + */ +@Data +@EqualsAndHashCode(callSuper = false) +@Accessors(chain = true) +@TableName("mes_xsl_biz_doc_registry") +@Schema(description = "审批注册中心") +public class MesXslBizDocRegistry extends JeecgEntity implements Serializable { + + private static final long serialVersionUID = 1L; + + @Schema(description = "业务编码,如 formula_spec") + private String docCode; + + @Schema(description = "物理表名") + private String tableName; + + @Schema(description = "中文名") + private String displayName; + + @Dict(dicCode = "yn") + @Schema(description = "启用 0否 1是") + private Integer enabled; + + //update-begin---author:GHT ---date:20260605 for:【XSLMES-20260605-K8R2】审批环节与字段映射配置----------- + @Dict(dicCode = "mes_xsl_approval_stage") + @Schema(description = "启用环节(多选逗号分隔 proofread,audit,approve)") + @TableField(updateStrategy = FieldStrategy.ALWAYS) + private String enabledStages; + + @Schema(description = "业务状态字段名") + private String statusField; + + @Schema(description = "校对人字段名") + private String proofreadByField; + + @Schema(description = "校对时间字段名") + private String proofreadTimeField; + + @Schema(description = "审核人字段名") + private String auditByField; + + @Schema(description = "审核时间字段名") + private String auditTimeField; + + @Schema(description = "批准人字段名") + private String approveByField; + + @Schema(description = "批准时间字段名") + private String approveTimeField; + //update-end---author:GHT ---date:20260605 for:【XSLMES-20260605-K8R2】审批环节与字段映射配置----------- + + @Schema(description = "备注") + private String remark; + + @Schema(description = "逻辑删除 0正常 1已删除") + @TableLogic + private Integer delFlag; + + @Schema(description = "租户ID") + private Integer tenantId; + + @Schema(description = "所属部门编码") + private String sysOrgCode; +} diff --git a/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/integration/entity/MesXslIntegrationAction.java b/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/integration/entity/MesXslIntegrationAction.java new file mode 100644 index 00000000..e173114b --- /dev/null +++ b/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/integration/entity/MesXslIntegrationAction.java @@ -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; +} diff --git a/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/integration/entity/MesXslIntegrationLog.java b/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/integration/entity/MesXslIntegrationLog.java new file mode 100644 index 00000000..76af8f7a --- /dev/null +++ b/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/integration/entity/MesXslIntegrationLog.java @@ -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; +} diff --git a/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/integration/entity/MesXslIntegrationPlan.java b/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/integration/entity/MesXslIntegrationPlan.java new file mode 100644 index 00000000..a1466675 --- /dev/null +++ b/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/integration/entity/MesXslIntegrationPlan.java @@ -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; +} diff --git a/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/integration/mapper/MesXslApprovalTraceMapper.java b/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/integration/mapper/MesXslApprovalTraceMapper.java new file mode 100644 index 00000000..997dd169 --- /dev/null +++ b/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/integration/mapper/MesXslApprovalTraceMapper.java @@ -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 { +} diff --git a/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/integration/mapper/MesXslBizDocRegistryMapper.java b/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/integration/mapper/MesXslBizDocRegistryMapper.java new file mode 100644 index 00000000..386d79f7 --- /dev/null +++ b/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/integration/mapper/MesXslBizDocRegistryMapper.java @@ -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 { +} diff --git a/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/integration/mapper/MesXslIntegrationActionMapper.java b/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/integration/mapper/MesXslIntegrationActionMapper.java new file mode 100644 index 00000000..4cc63f83 --- /dev/null +++ b/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/integration/mapper/MesXslIntegrationActionMapper.java @@ -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 { +} diff --git a/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/integration/mapper/MesXslIntegrationLogMapper.java b/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/integration/mapper/MesXslIntegrationLogMapper.java new file mode 100644 index 00000000..c77a9e0d --- /dev/null +++ b/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/integration/mapper/MesXslIntegrationLogMapper.java @@ -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 { +} diff --git a/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/integration/mapper/MesXslIntegrationPlanMapper.java b/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/integration/mapper/MesXslIntegrationPlanMapper.java new file mode 100644 index 00000000..b87f6ff1 --- /dev/null +++ b/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/integration/mapper/MesXslIntegrationPlanMapper.java @@ -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 { +} diff --git a/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/integration/mapper/xml/MesXslApprovalTraceMapper.xml b/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/integration/mapper/xml/MesXslApprovalTraceMapper.xml new file mode 100644 index 00000000..3f815df8 --- /dev/null +++ b/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/integration/mapper/xml/MesXslApprovalTraceMapper.xml @@ -0,0 +1,4 @@ + + + + diff --git a/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/integration/mapper/xml/MesXslBizDocRegistryMapper.xml b/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/integration/mapper/xml/MesXslBizDocRegistryMapper.xml new file mode 100644 index 00000000..04229d65 --- /dev/null +++ b/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/integration/mapper/xml/MesXslBizDocRegistryMapper.xml @@ -0,0 +1,4 @@ + + + + diff --git a/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/integration/mapper/xml/MesXslIntegrationActionMapper.xml b/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/integration/mapper/xml/MesXslIntegrationActionMapper.xml new file mode 100644 index 00000000..5a86a4f2 --- /dev/null +++ b/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/integration/mapper/xml/MesXslIntegrationActionMapper.xml @@ -0,0 +1,4 @@ + + + + diff --git a/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/integration/mapper/xml/MesXslIntegrationLogMapper.xml b/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/integration/mapper/xml/MesXslIntegrationLogMapper.xml new file mode 100644 index 00000000..ef255f8e --- /dev/null +++ b/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/integration/mapper/xml/MesXslIntegrationLogMapper.xml @@ -0,0 +1,4 @@ + + + + diff --git a/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/integration/mapper/xml/MesXslIntegrationPlanMapper.xml b/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/integration/mapper/xml/MesXslIntegrationPlanMapper.xml new file mode 100644 index 00000000..0bae6341 --- /dev/null +++ b/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/integration/mapper/xml/MesXslIntegrationPlanMapper.xml @@ -0,0 +1,4 @@ + + + + diff --git a/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/integration/service/IApprovalTraceSyncService.java b/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/integration/service/IApprovalTraceSyncService.java new file mode 100644 index 00000000..e2f1068e --- /dev/null +++ b/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/integration/service/IApprovalTraceSyncService.java @@ -0,0 +1,26 @@ +package org.jeecg.modules.xslmes.approval.integration.service; + +import java.util.Date; + +/** + * 审批痕迹双写同步服务 + */ +public interface IApprovalTraceSyncService { + + /** + * 校验业务表是否已启用指定审批环节;未注册配置时返回 null(不拦截业务) + */ + String checkStageAllowed(String bizTable, String stage); + + /** + * 环节通过后同步痕迹(upsert 每单据一行) + */ + void syncStage(String bizTable, String bizDataId, String stage, String operatorBy, Date operatorTime); + + /** + * 逆向回退时同步清空高于目标环节的痕迹字段 + * + * @param targetStage compile / proofread / audit + */ + void revertToStage(String bizTable, String bizDataId, String targetStage); +} diff --git a/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/integration/service/IMesXslApprovalTraceService.java b/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/integration/service/IMesXslApprovalTraceService.java new file mode 100644 index 00000000..94bdf02a --- /dev/null +++ b/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/integration/service/IMesXslApprovalTraceService.java @@ -0,0 +1,15 @@ +package org.jeecg.modules.xslmes.approval.integration.service; + +import com.baomidou.mybatisplus.extension.service.IService; +import org.jeecg.modules.xslmes.approval.integration.entity.MesXslApprovalTrace; + +/** + * 审批痕迹明细 + */ +public interface IMesXslApprovalTraceService extends IService { + + /** + * 按业务表 + 单据ID 查询痕迹(供业务页关联展示) + */ + MesXslApprovalTrace getByBiz(String bizTable, String bizDataId); +} diff --git a/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/integration/service/IMesXslBizDocRegistryService.java b/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/integration/service/IMesXslBizDocRegistryService.java new file mode 100644 index 00000000..e150d6cb --- /dev/null +++ b/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/integration/service/IMesXslBizDocRegistryService.java @@ -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 { + + /** + * 保存前规范化字段映射与环节配置 + */ + void normalizeBeforeSave(MesXslBizDocRegistry entity); + + /** + * 按物理表名查询已启用的审批注册配置 + */ + MesXslBizDocRegistry findActiveByTableName(String tableName); +} diff --git a/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/integration/service/IMesXslIntegrationActionService.java b/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/integration/service/IMesXslIntegrationActionService.java new file mode 100644 index 00000000..c6da8345 --- /dev/null +++ b/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/integration/service/IMesXslIntegrationActionService.java @@ -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 { + + /** 按方案ID查询启用动作,按 exec_order 升序 */ + List listByPlanId(String planId); + + /** 删除方案下所有动作 */ + void removeByPlanId(String planId); +} diff --git a/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/integration/service/IMesXslIntegrationLogService.java b/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/integration/service/IMesXslIntegrationLogService.java new file mode 100644 index 00000000..a9e48457 --- /dev/null +++ b/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/integration/service/IMesXslIntegrationLogService.java @@ -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 { + + /** 幂等检查:同一 idempotentKey 是否已 success */ + boolean isAlreadySuccess(String idempotentKey); + + /** 手动重试失败日志(重新触发 Orchestrator 执行对应 action) */ + Result retry(String logId); +} diff --git a/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/integration/service/IMesXslIntegrationPlanService.java b/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/integration/service/IMesXslIntegrationPlanService.java new file mode 100644 index 00000000..72174bad --- /dev/null +++ b/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/integration/service/IMesXslIntegrationPlanService.java @@ -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 { + + /** 发布方案:status 0→1 */ + Result publish(String planId); + + /** 停用方案:status 1→2 */ + Result disable(String planId); + + /** + * 保存前绑定注册中心并校验环节 + */ + Result normalizeAndValidate(MesXslIntegrationPlan plan); +} diff --git a/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/integration/service/IntegrationPlanGenerator.java b/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/integration/service/IntegrationPlanGenerator.java new file mode 100644 index 00000000..b328930d --- /dev/null +++ b/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/integration/service/IntegrationPlanGenerator.java @@ -0,0 +1,657 @@ +package org.jeecg.modules.xslmes.approval.integration.service; + +import com.alibaba.fastjson2.JSON; +import com.alibaba.fastjson2.JSONArray; +import com.alibaba.fastjson2.JSONObject; +import lombok.extern.slf4j.Slf4j; +import org.jeecg.common.api.vo.Result; +import org.jeecg.common.util.oConvertUtils; +import org.jeecg.modules.xslmes.approval.entity.MesXslApprovalFlow; +import org.jeecg.modules.xslmes.approval.integration.engine.ApprovalStageResolver; +import org.jeecg.modules.xslmes.approval.integration.engine.RegistryStageFieldHelper; +import org.jeecg.modules.xslmes.approval.integration.entity.MesXslBizDocRegistry; +import org.jeecg.modules.xslmes.approval.integration.entity.MesXslIntegrationAction; +import org.jeecg.modules.xslmes.approval.integration.entity.MesXslIntegrationPlan; +import org.jeecg.modules.xslmes.approval.integration.service.IMesXslBizDocRegistryService; +import org.jeecg.modules.xslmes.approval.integration.service.IMesXslIntegrationActionService; +import org.jeecg.modules.xslmes.approval.integration.service.IMesXslIntegrationPlanService; +import org.jeecg.modules.xslmes.approval.service.IMesXslApprovalFlowService; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * 按审批流程节点 + 业务状态字典,一键生成默认集成方案与动作。 + * + * @author GHT + * @date 2026-06-05 for:【XSLMES-20260605-K8R2】按流程生成默认集成方案 + */ +@Slf4j +@Service +public class IntegrationPlanGenerator { + + private static final Pattern DICT_IN_COMMENT = Pattern.compile("字典[:\\s]?([a-zA-Z][a-zA-Z0-9_]*)"); + + private static final Map 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> preview(String sourceTable, String flowId) { + return preview(sourceTable, flowId, null); + } + + public Result> preview(String sourceTable, String flowId, Map 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> generate(String sourceTable, String flowId, boolean overwriteDraft) { + return generate(sourceTable, flowId, overwriteDraft, null); + } + + @Transactional(rollbackFor = Exception.class) + public Result> generate(String sourceTable, String flowId, boolean overwriteDraft, + Map stageOverrides) { + Map preview = buildPreview(sourceTable, flowId, stageOverrides); + MesXslBizDocRegistry registry = registryService.findActiveByTableName(sourceTable); + String codePrefix = planCodePrefix(registry); + + if (overwriteDraft) { + removeDraftAutoPlans(sourceTable, codePrefix); + } + + @SuppressWarnings("unchecked") + List> planDefs = (List>) preview.get("plans"); + int created = 0; + int skipped = 0; + List planCodes = new ArrayList<>(); + + for (Map 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 validate = planService.normalizeAndValidate(plan); + if (!validate.isSuccess()) { + throw new IllegalStateException("方案校验失败[" + planCode + "]: " + validate.getMessage()); + } + planService.save(plan); + + @SuppressWarnings("unchecked") + Map actionDef = (Map) 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 result = new LinkedHashMap<>(preview); + result.put("created", created); + result.put("skipped", skipped); + result.put("planCodes", planCodes); + return Result.OK("生成完成:新增 " + created + " 个方案,跳过 " + skipped + " 个已存在方案", result); + } + //update-end---author:GHT ---date:20260605 for:【XSLMES-20260605-K8R2】按流程生成默认集成方案与动作----------- + + private Map buildPreview(String sourceTable, String flowId, Map 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 enabledStages = orderedEnabledStages(registry); + + List flowNodes = parseApproverNodes(flow.getFlowConfig()); + if (flowNodes.isEmpty()) { + throw new IllegalArgumentException("审批流程中无审批人节点,请先设计流程"); + } + + List 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 bindings = bindAllFlowNodes(flowNodes, registry, enabledStages, statusChain, initialStatus, stageOverrides); + + List 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> plans = new ArrayList<>(); + List> 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 node = new LinkedHashMap<>(); + node.put("nodeIndex", i + 1); + node.put("nodeId", b.nodeId); + node.put("nodeName", b.nodeName); + node.put("nodeNameDisplay", b.nodeName + (b.stageConfigured ? "(已配置该环节)" : "(未配置该环节)")); + node.put("stageConfigured", b.stageConfigured); + node.put("configuredText", b.stageConfigured ? "已配置该环节" : "未配置该环节"); + node.put("stage", b.stage); + node.put("suggestedStage", b.suggestedStage); + node.put("stageLabel", oConvertUtils.isNotEmpty(b.stageLabel) ? b.stageLabel : "-"); + node.put("willGenerate", willGenerate); + node.put("triggerPhase", phase); + node.put("expectedFrom", b.expectedFrom); + node.put("expectedFromLabel", oConvertUtils.isNotEmpty(b.expectedFrom) ? labelOf(statusChain, b.expectedFrom) : "-"); + if (!b.stageConfigured && oConvertUtils.isNotEmpty(b.unconfiguredReason)) { + node.put("unconfiguredReason", b.unconfiguredReason); + } + nodePreview.add(node); + + if (!willGenerate) { + continue; + } + + String planCode = codePrefix + "_reg_" + b.stage; + String planName = displayName + "-" + b.stageLabel + "通过(流程生成)"; + + Map actionConfig = new LinkedHashMap<>(); + actionConfig.put("visualType", "REGISTRY_STAGE_SYNC"); + actionConfig.put("stage", b.stage); + actionConfig.put("expectedFrom", b.expectedFrom); + + Map action = new LinkedHashMap<>(); + action.put("actionName", b.stageLabel + "环节同步"); + action.put("actionType", "REGISTRY_STAGE_SYNC"); + action.put("actionConfig", actionConfig); + + Map 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 rejectActionConfig = new LinkedHashMap<>(); + rejectActionConfig.put("visualType", "REGISTRY_STAGE_REVERT"); + rejectActionConfig.put("targetStage", initialStatus); + + Map rejectAction = new LinkedHashMap<>(); + rejectAction.put("actionName", "驳回回退" + labelOf(statusChain, initialStatus)); + rejectAction.put("actionType", "REGISTRY_STAGE_REVERT"); + rejectAction.put("actionConfig", rejectActionConfig); + + Map 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 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> buildStageOptions(List statusChain) { + List> options = new ArrayList<>(); + for (String stage : new String[]{ + ApprovalStageResolver.STAGE_PROOFREAD, + ApprovalStageResolver.STAGE_AUDIT, + ApprovalStageResolver.STAGE_APPROVE}) { + Map opt = new LinkedHashMap<>(); + opt.put("value", stage); + opt.put("label", labelOf(statusChain, stage)); + options.add(opt); + } + return options; + } + + private Map buildStageMeta(MesXslBizDocRegistry registry, List statusChain) { + Map meta = new LinkedHashMap<>(); + Set enabled = ApprovalStageResolver.parseEnabledStages(registry.getEnabledStages()); + for (String stage : new String[]{ + ApprovalStageResolver.STAGE_PROOFREAD, + ApprovalStageResolver.STAGE_AUDIT, + ApprovalStageResolver.STAGE_APPROVE}) { + Map 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 parseStageOverrides(List> nodeBindings) { + if (nodeBindings == null || nodeBindings.isEmpty()) { + return null; + } + Map overrides = new LinkedHashMap<>(); + for (Map 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 orderedEnabledStages(MesXslBizDocRegistry registry) { + Set enabled = ApprovalStageResolver.parseEnabledStages(registry.getEnabledStages()); + List 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 parseApproverNodes(String flowConfig) { + List nodes = new ArrayList<>(); + collectApproverNodes(JSONObject.parseObject(flowConfig), nodes); + return nodes; + } + + private void collectApproverNodes(JSONObject node, List out) { + if (node == null) { + return; + } + if ("approver".equals(node.getString("type"))) { + JSONObject props = node.getJSONObject("properties"); + if (props == null) { + props = new JSONObject(); + } + String name = props.getString("name"); + if (oConvertUtils.isEmpty(name)) { + name = node.getString("name"); + } + out.add(new FlowNode( + oConvertUtils.isNotEmpty(name) ? name : "审批节点" + (out.size() + 1), + node.getString("id"), + props)); + } + JSONArray branches = node.getJSONArray("conditionNodes"); + if (branches != null && !branches.isEmpty()) { + Object first = branches.get(0); + if (first instanceof JSONObject branch) { + collectApproverNodes(branch.getJSONObject("childNode"), out); + } + } + collectApproverNodes(node.getJSONObject("childNode"), out); + } + + //update-begin---author:GHT ---date:20260605 for:【XSLMES-20260605-K8R2】遍历全部流程节点解析环节并判断注册中心是否已配置----------- + private List bindAllFlowNodes(List flowNodes, + MesXslBizDocRegistry registry, + List enabledStages, + List statusChain, + String initialStatus, + Map stageOverrides) { + List bindings = new ArrayList<>(); + for (int i = 0; i < flowNodes.size(); i++) { + FlowNode node = flowNodes.get(i); + String suggestedStage = resolveStageFromNode(node, registry, enabledStages, i); + String stage = suggestedStage; + if (stageOverrides != null && stageOverrides.containsKey(node.nodeId)) { + stage = stageOverrides.get(node.nodeId); + } + boolean configured = isStageConfigured(registry, stage); + String unconfiguredReason = null; + if (!configured) { + unconfiguredReason = buildUnconfiguredReason(registry, stage, enabledStages); + } + String stageLabel = "-"; + if (oConvertUtils.isNotEmpty(stage)) { + stageLabel = labelOf(statusChain, stage); + if (oConvertUtils.isEmpty(stageLabel) || stage.equals(stageLabel)) { + stageLabel = ApprovalStageResolver.stageLabel(stage); + } + } + bindings.add(new StageBinding( + node.name, node.nodeId, stage, stageLabel, null, configured, unconfiguredReason, suggestedStage)); + } + for (int i = 0; i < bindings.size(); i++) { + StageBinding b = bindings.get(i); + String expectedFrom = b.stageConfigured + ? resolveExpectedFromForBinding(bindings, i, statusChain, initialStatus) + : null; + bindings.set(i, b.withExpectedFrom(expectedFrom)); + } + return bindings; + } + + private String resolveStageFromNode(FlowNode node, MesXslBizDocRegistry registry, + List enabledStages, int nodeIndex) { + JSONObject props = node.props; + if (props != null) { + String stageKey = props.getString("stageKey"); + if (oConvertUtils.isNotEmpty(stageKey)) { + return stageKey.trim(); + } + String fromField = mapFieldToStage(registry, props.getString("fieldName")); + if (oConvertUtils.isNotEmpty(fromField)) { + return fromField; + } + } + String fromName = ApprovalStageResolver.resolveStageFromNodeName(node.name); + if (oConvertUtils.isNotEmpty(fromName)) { + return fromName; + } + if (nodeIndex < enabledStages.size()) { + return enabledStages.get(nodeIndex); + } + return null; + } + + private String mapFieldToStage(MesXslBizDocRegistry registry, String fieldName) { + if (oConvertUtils.isEmpty(fieldName) || registry == null) { + return null; + } + if (fieldName.equals(registry.getProofreadByField())) { + return ApprovalStageResolver.STAGE_PROOFREAD; + } + if (fieldName.equals(registry.getAuditByField())) { + return ApprovalStageResolver.STAGE_AUDIT; + } + if (fieldName.equals(registry.getApproveByField())) { + return ApprovalStageResolver.STAGE_APPROVE; + } + return null; + } + + private boolean isStageConfigured(MesXslBizDocRegistry registry, String stage) { + if (registry == null || oConvertUtils.isEmpty(stage)) { + return false; + } + Set enabled = ApprovalStageResolver.parseEnabledStages(registry.getEnabledStages()); + if (!enabled.contains(stage)) { + return false; + } + return oConvertUtils.isNotEmpty(RegistryStageFieldHelper.byField(registry, stage)); + } + + private String buildUnconfiguredReason(MesXslBizDocRegistry registry, String stage, List enabledStages) { + if (oConvertUtils.isEmpty(stage)) { + return "未选择审批环节"; + } + Set enabled = ApprovalStageResolver.parseEnabledStages(registry.getEnabledStages()); + if (!enabled.contains(stage)) { + return "环节「" + ApprovalStageResolver.stageLabel(stage) + "」未在注册中心启用"; + } + if (oConvertUtils.isEmpty(RegistryStageFieldHelper.byField(registry, stage))) { + return "环节「" + ApprovalStageResolver.stageLabel(stage) + "」未配置操作人字段"; + } + return "环节未完整配置"; + } + + private String resolveExpectedFromForBinding(List bindings, int index, + List 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 chain, List enabledStages) { + Set 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 loadStatusChain(MesXslBizDocRegistry registry) { + String dictCode = resolveStatusDictCode(registry); + if (oConvertUtils.isEmpty(dictCode)) { + return List.of(); + } + List> 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 chain = new ArrayList<>(); + for (Map 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 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 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 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 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 toMap() { + Map 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 toMap() { + Map m = new LinkedHashMap<>(); + m.put("value", value); + m.put("label", label); + return m; + } + } + + private record StageBinding(String nodeName, String nodeId, String stage, String stageLabel, + String expectedFrom, boolean stageConfigured, String unconfiguredReason, + String suggestedStage) { + StageBinding withExpectedFrom(String expectedFrom) { + return new StageBinding(nodeName, nodeId, stage, stageLabel, expectedFrom, stageConfigured, unconfiguredReason, suggestedStage); + } + } +} diff --git a/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/integration/service/impl/ApprovalTraceSyncServiceImpl.java b/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/integration/service/impl/ApprovalTraceSyncServiceImpl.java new file mode 100644 index 00000000..2ad0de81 --- /dev/null +++ b/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/integration/service/impl/ApprovalTraceSyncServiceImpl.java @@ -0,0 +1,144 @@ +package org.jeecg.modules.xslmes.approval.integration.service.impl; + +import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper; +import java.util.Arrays; +import java.util.Date; +import java.util.HashSet; +import java.util.Set; +import org.jeecg.common.util.oConvertUtils; +import org.jeecg.modules.xslmes.approval.integration.entity.MesXslApprovalTrace; +import org.jeecg.modules.xslmes.approval.integration.entity.MesXslBizDocRegistry; +import org.jeecg.modules.xslmes.approval.integration.service.IApprovalTraceSyncService; +import org.jeecg.modules.xslmes.approval.integration.service.IMesXslApprovalTraceService; +import org.jeecg.modules.xslmes.approval.integration.service.IMesXslBizDocRegistryService; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +/** + * 审批痕迹双写同步 + * + * @author GHT + * @date 2026-06-05 for:【XSLMES-20260605-K8R2】审批痕迹双写 + */ +@Service +public class ApprovalTraceSyncServiceImpl implements IApprovalTraceSyncService { + + private static final String STAGE_PROOFREAD = "proofread"; + private static final String STAGE_AUDIT = "audit"; + private static final String STAGE_APPROVE = "approve"; + + @Autowired + private IMesXslBizDocRegistryService registryService; + + @Autowired + private IMesXslApprovalTraceService traceService; + + @Override + public String checkStageAllowed(String bizTable, String stage) { + MesXslBizDocRegistry registry = findActiveRegistry(bizTable); + if (registry == null || oConvertUtils.isEmpty(registry.getEnabledStages())) { + return null; + } + if (!containsStage(registry.getEnabledStages(), stage)) { + return "业务表[" + registry.getDisplayName() + "]未启用「" + stageLabel(stage) + "」环节"; + } + return null; + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void syncStage(String bizTable, String bizDataId, String stage, String operatorBy, Date operatorTime) { + MesXslBizDocRegistry registry = findActiveRegistry(bizTable); + if (registry == null || !containsStage(registry.getEnabledStages(), stage)) { + return; + } + MesXslApprovalTrace trace = traceService.getByBiz(bizTable, bizDataId); + if (trace == null) { + trace = new MesXslApprovalTrace() + .setRegistryId(registry.getId()) + .setBizTable(bizTable) + .setBizDataId(bizDataId); + } + Date opTime = operatorTime == null ? new Date() : operatorTime; + switch (stage) { + case STAGE_PROOFREAD: + trace.setProofreadBy(operatorBy); + trace.setProofreadTime(opTime); + break; + case STAGE_AUDIT: + trace.setAuditBy(operatorBy); + trace.setAuditTime(opTime); + break; + case STAGE_APPROVE: + trace.setApproveBy(operatorBy); + trace.setApproveTime(opTime); + break; + default: + return; + } + traceService.saveOrUpdate(trace); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void revertToStage(String bizTable, String bizDataId, String targetStage) { + MesXslApprovalTrace trace = traceService.getByBiz(bizTable, bizDataId); + if (trace == null) { + return; + } + LambdaUpdateWrapper wrapper = new LambdaUpdateWrapper<>(); + wrapper.eq(MesXslApprovalTrace::getId, trace.getId()); + if ("compile".equals(targetStage)) { + wrapper.set(MesXslApprovalTrace::getProofreadBy, null) + .set(MesXslApprovalTrace::getProofreadTime, null) + .set(MesXslApprovalTrace::getAuditBy, null) + .set(MesXslApprovalTrace::getAuditTime, null) + .set(MesXslApprovalTrace::getApproveBy, null) + .set(MesXslApprovalTrace::getApproveTime, null); + } else if (STAGE_PROOFREAD.equals(targetStage)) { + wrapper.set(MesXslApprovalTrace::getAuditBy, null) + .set(MesXslApprovalTrace::getAuditTime, null) + .set(MesXslApprovalTrace::getApproveBy, null) + .set(MesXslApprovalTrace::getApproveTime, null); + } else if (STAGE_AUDIT.equals(targetStage)) { + wrapper.set(MesXslApprovalTrace::getApproveBy, null) + .set(MesXslApprovalTrace::getApproveTime, null); + } else { + return; + } + traceService.update(wrapper); + } + + private MesXslBizDocRegistry findActiveRegistry(String bizTable) { + if (oConvertUtils.isEmpty(bizTable)) { + return null; + } + return registryService.lambdaQuery() + .eq(MesXslBizDocRegistry::getTableName, bizTable) + .eq(MesXslBizDocRegistry::getEnabled, 1) + .last("LIMIT 1") + .one(); + } + + private boolean containsStage(String enabledStages, String stage) { + if (oConvertUtils.isEmpty(enabledStages) || oConvertUtils.isEmpty(stage)) { + return false; + } + Set 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; + } + } +} diff --git a/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/integration/service/impl/MesXslApprovalTraceServiceImpl.java b/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/integration/service/impl/MesXslApprovalTraceServiceImpl.java new file mode 100644 index 00000000..7f1b3737 --- /dev/null +++ b/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/integration/service/impl/MesXslApprovalTraceServiceImpl.java @@ -0,0 +1,27 @@ +package org.jeecg.modules.xslmes.approval.integration.service.impl; + +import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; +import org.jeecg.common.util.oConvertUtils; +import org.jeecg.modules.xslmes.approval.integration.entity.MesXslApprovalTrace; +import org.jeecg.modules.xslmes.approval.integration.mapper.MesXslApprovalTraceMapper; +import org.jeecg.modules.xslmes.approval.integration.service.IMesXslApprovalTraceService; +import org.springframework.stereotype.Service; + +/** + * 审批痕迹明细 + */ +@Service +public class MesXslApprovalTraceServiceImpl extends ServiceImpl + implements IMesXslApprovalTraceService { + + @Override + public MesXslApprovalTrace getByBiz(String bizTable, String bizDataId) { + if (oConvertUtils.isEmpty(bizTable) || oConvertUtils.isEmpty(bizDataId)) { + return null; + } + return lambdaQuery() + .eq(MesXslApprovalTrace::getBizTable, bizTable) + .eq(MesXslApprovalTrace::getBizDataId, bizDataId) + .one(); + } +} diff --git a/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/integration/service/impl/MesXslBizDocRegistryServiceImpl.java b/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/integration/service/impl/MesXslBizDocRegistryServiceImpl.java new file mode 100644 index 00000000..ed58a9af --- /dev/null +++ b/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/integration/service/impl/MesXslBizDocRegistryServiceImpl.java @@ -0,0 +1,71 @@ +package org.jeecg.modules.xslmes.approval.integration.service.impl; + +import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; +import org.jeecg.common.util.oConvertUtils; +import org.jeecg.modules.xslmes.approval.integration.entity.MesXslBizDocRegistry; +import org.jeecg.modules.xslmes.approval.integration.mapper.MesXslBizDocRegistryMapper; +import org.jeecg.modules.xslmes.approval.integration.service.IMesXslBizDocRegistryService; +import org.springframework.stereotype.Service; + +/** + * 审批注册中心 + */ +@Service +public class MesXslBizDocRegistryServiceImpl extends ServiceImpl + implements IMesXslBizDocRegistryService { + + //update-begin---author:GHT ---date:20260605 for:【XSLMES-20260605-K8R2】保存前规范化审批注册配置----------- + @Override + public void normalizeBeforeSave(MesXslBizDocRegistry entity) { + if (entity == null) { + return; + } + if (entity.getEnabled() == null) { + entity.setEnabled(1); + } + entity.setEnabledStages(normalizeStages(entity.getEnabledStages())); + entity.setStatusField(defaultField(entity.getStatusField(), "status")); + entity.setProofreadByField(defaultField(entity.getProofreadByField(), "proofread_by")); + entity.setProofreadTimeField(defaultField(entity.getProofreadTimeField(), "proofread_time")); + entity.setAuditByField(defaultField(entity.getAuditByField(), "audit_by")); + entity.setAuditTimeField(defaultField(entity.getAuditTimeField(), "audit_time")); + entity.setApproveByField(defaultField(entity.getApproveByField(), "approve_by")); + entity.setApproveTimeField(defaultField(entity.getApproveTimeField(), "approve_time")); + } + + private String normalizeStages(String stages) { + if (oConvertUtils.isEmpty(stages)) { + return null; + } + String[] parts = stages.split(","); + StringBuilder sb = new StringBuilder(); + for (String part : parts) { + if (oConvertUtils.isEmpty(part)) { + continue; + } + String val = part.trim(); + if (sb.length() > 0) { + sb.append(','); + } + sb.append(val); + } + return sb.length() == 0 ? null : sb.toString(); + } + + private String defaultField(String value, String fallback) { + return oConvertUtils.isEmpty(value) ? fallback : value.trim(); + } + //update-end---author:GHT ---date:20260605 for:【XSLMES-20260605-K8R2】保存前规范化审批注册配置----------- + + @Override + public MesXslBizDocRegistry findActiveByTableName(String tableName) { + if (oConvertUtils.isEmpty(tableName)) { + return null; + } + return lambdaQuery() + .eq(MesXslBizDocRegistry::getTableName, tableName) + .eq(MesXslBizDocRegistry::getEnabled, 1) + .last("LIMIT 1") + .one(); + } +} diff --git a/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/integration/service/impl/MesXslIntegrationActionServiceImpl.java b/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/integration/service/impl/MesXslIntegrationActionServiceImpl.java new file mode 100644 index 00000000..6d6dd30f --- /dev/null +++ b/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/integration/service/impl/MesXslIntegrationActionServiceImpl.java @@ -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 + implements IMesXslIntegrationActionService { + + @Override + public List 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() + .eq(MesXslIntegrationAction::getPlanId, planId)); + } +} diff --git a/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/integration/service/impl/MesXslIntegrationLogServiceImpl.java b/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/integration/service/impl/MesXslIntegrationLogServiceImpl.java new file mode 100644 index 00000000..55b590bd --- /dev/null +++ b/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/integration/service/impl/MesXslIntegrationLogServiceImpl.java @@ -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 + implements IMesXslIntegrationLogService { + + @Override + public boolean isAlreadySuccess(String idempotentKey) { + return lambdaQuery() + .eq(MesXslIntegrationLog::getIdempotentKey, idempotentKey) + .eq(MesXslIntegrationLog::getStatus, "success") + .exists(); + } + + @Override + public Result retry(String logId) { + // Phase 0 先留钩子,Phase 2 完善手动重试 + return Result.error("手动重试功能将在 Phase 2 完善"); + } +} diff --git a/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/integration/service/impl/MesXslIntegrationPlanServiceImpl.java b/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/integration/service/impl/MesXslIntegrationPlanServiceImpl.java new file mode 100644 index 00000000..fae009fb --- /dev/null +++ b/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/integration/service/impl/MesXslIntegrationPlanServiceImpl.java @@ -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 + implements IMesXslIntegrationPlanService { + + @Autowired + private IMesXslBizDocRegistryService registryService; + + @Override + public Result publish(String planId) { + MesXslIntegrationPlan plan = getById(planId); + if (plan == null) { + return Result.error("方案不存在"); + } + if ("1".equals(plan.getStatus())) { + return Result.error("方案已是发布状态"); + } + Result 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 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 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 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】集成方案绑定审批注册中心环节----------- +} diff --git a/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/service/impl/MesXslApprovalHandleServiceImpl.java b/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/service/impl/MesXslApprovalHandleServiceImpl.java index 1a77479d..11b40107 100644 --- a/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/service/impl/MesXslApprovalHandleServiceImpl.java +++ b/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/service/impl/MesXslApprovalHandleServiceImpl.java @@ -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 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,不触发业务级联同步,确保单据回到“可重新提交”的初始态。 diff --git a/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/controller/MesXslMixerPsCompileController.java b/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/controller/MesXslMixerPsCompileController.java index 8b4a26e4..e78cabbe 100644 --- a/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/controller/MesXslMixerPsCompileController.java +++ b/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/controller/MesXslMixerPsCompileController.java @@ -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 reject(@RequestParam(name = "ids") String ids) { String err = mesXslMixerPsCompileService.rejectBatch(ids, getOperatorName()); @@ -258,6 +258,8 @@ public class MesXslMixerPsCompileController extends JeecgController status={}", processInstanceId, status); + log.info("{} 台账已更新 instanceId={} -> status={}", LOG_TAG, processInstanceId, status); } catch (Exception e) { - log.error("[DingBpms] 台账更新失败 instanceId={}: {}", processInstanceId, e.getMessage(), e); + log.error("{} 台账更新失败 instanceId={}: {}", LOG_TAG, processInstanceId, e.getMessage(), e); return; } //update-end---author:GHT ---date:2026-06-04 for:【20260604】钉钉回调幂等去重:finishByExternalInstance条件为status=RUNNING,0行更新即终态已处理----- if (ApprovalRecordConstants.STATUS_CANCELLED.equals(status)) { + log.info("{} bpms_instance_change 终止态不触发业务回调 instanceId={}", LOG_TAG, processInstanceId); return; } // ③ 拉取完整审批实例 MesXslApprovalRecord record = findRecord(processInstanceId); - if (record == null || oConvertUtils.isEmpty(record.getBizTable())) { + if (record == null) { + log.warn("{} bpms_instance_change 跳过:未找到台账 instanceId={}", LOG_TAG, processInstanceId); return; } + if (oConvertUtils.isEmpty(record.getBizTable())) { + log.warn("{} bpms_instance_change 跳过:台账无 bizTable recordId={} instanceId={}", + LOG_TAG, record.getId(), processInstanceId); + return; + } + + log.info("{} 台账命中 recordId={} bizTable={} bizDataId={} flowId={} flowName={} originStatus={}", + LOG_TAG, record.getId(), record.getBizTable(), record.getBizDataId(), + record.getFlowId(), record.getFlowName(), record.getOriginStatus()); + JSONObject instance = workflowService.getProcessInstance(processInstanceId); + if (instance == null) { + log.warn("{} 拉取钉钉实例详情失败 instanceId={}", LOG_TAG, processInstanceId); + } List taskOps = workflowService.getTaskOperations(instance); List mesNodes = loadApproverNodes(record.getFlowId()); + log.info("{} 实例解析 instanceId={} taskOpCount={} mesApproverNodeCount={} mesNodeNames={} taskOps={}", + LOG_TAG, processInstanceId, taskOps.size(), mesNodes.size(), + summarizeNodeNames(mesNodes), summarizeTaskOps(taskOps)); + ApprovalCallbackContext ctx = buildContext(record, remark); if (ApprovalRecordConstants.STATUS_APPROVED.equals(status)) { - // 最终通过:执行最后一个节点的 onApprove(用最后审批人的 token) if (!mesNodes.isEmpty() && !taskOps.isEmpty()) { JSONObject lastOp = taskOps.get(taskOps.size() - 1); String lastDtUserId = lastOp.getString("userId"); String token = workflowService.generateTokenByDtUserId(lastDtUserId); JSONObject lastNode = mesNodes.get(mesNodes.size() - 1); - actionHttpExecutor.run(lastNode, "onApprove", record.getBizDataId(), token); + log.info("{} 终态通过:最后节点 nodeId={} nodeName={} dtUserId={} tokenGenerated={}", + LOG_TAG, lastNode.getString("id"), lastNode.getString("name"), + lastDtUserId, oConvertUtils.isNotEmpty(token)); } + //update-begin---author:GHT ---date:20260605 for:【XSLMES-20260605-K8R2】钉钉终态回调补充节点信息供集成引擎匹配环节----------- + if (!mesNodes.isEmpty()) { + JSONObject lastNode = mesNodes.get(mesNodes.size() - 1); + ctx.setNodeId(lastNode.getString("id")).setNodeName(lastNode.getString("name")); + } + //update-end---author:GHT ---date:20260605 for:【XSLMES-20260605-K8R2】钉钉终态回调补充节点信息供集成引擎匹配环节----------- + logCallbackDispatch("fireApproved", ctx); callbackDispatcher.fireApproved(ctx); } else { - // 驳回:复用与 MES 内部审批相同的 isBizAtOriginStatus 逻辑 - // 台账已在发起时快照了 originStatus;若单据状态仍为原值,说明未被推进,跳过 onReject JSONObject refuseOp = findRefuseOp(taskOps); if (refuseOp != null) { int refuseIndex = taskOps.indexOf(refuseOp); boolean bizAtOrigin = isBizAtOriginStatus(record); + String currentBizStatus = readBizStatus(record); + log.info("{} 终态驳回:refuseIndex={} bizAtOrigin={} originStatus={} currentBizStatus={} refuseOp={}", + LOG_TAG, refuseIndex, bizAtOrigin, record.getOriginStatus(), currentBizStatus, + refuseOp.toJSONString()); if (!bizAtOrigin && refuseIndex < mesNodes.size()) { - // 单据已被前面节点推进,需要调 onReject 回退 String dtUserId = refuseOp.getString("userId"); String token = workflowService.generateTokenByDtUserId(dtUserId); - try { - actionHttpExecutor.run(mesNodes.get(refuseIndex), "onReject", - record.getBizDataId(), token); - } catch (Exception e) { - log.error("[DingBpms] onReject HTTP 回调失败: {}", e.getMessage()); - } + JSONObject refuseNode = mesNodes.get(refuseIndex); + log.info("{} 终态驳回:业务已推进,触发 onReject 集成 nodeId={} nodeName={} tokenGenerated={}", + LOG_TAG, refuseNode.getString("id"), refuseNode.getString("name"), + oConvertUtils.isNotEmpty(token)); } else { - log.info("[DingBpms] 单据仍处于发起前原始状态,跳过 onReject 回调 instanceId={}", - processInstanceId); + log.info("{} 终态驳回:跳过业务 onReject(单据仍在发起前状态) instanceId={}", + LOG_TAG, processInstanceId); + } + } else { + log.info("{} 终态驳回:operationRecords 中未找到 REFUSE 记录 instanceId={}", + LOG_TAG, processInstanceId); + } + if (!mesNodes.isEmpty() && refuseOp != null) { + int refuseIndex = taskOps.indexOf(refuseOp); + if (refuseIndex >= 0 && refuseIndex < mesNodes.size()) { + JSONObject refuseNode = mesNodes.get(refuseIndex); + ctx.setNodeId(refuseNode.getString("id")).setNodeName(refuseNode.getString("name")); } } + logCallbackDispatch("fireRejected", ctx); callbackDispatcher.fireRejected(ctx); } - log.info("[DingBpms] 终态回调完成 bizTable={} bizDataId={} status={}", - record.getBizTable(), record.getBizDataId(), status); + log.info("{} bpms_instance_change 完成 instanceId={} bizTable={} bizDataId={} mesStatus={}", + LOG_TAG, processInstanceId, record.getBizTable(), record.getBizDataId(), status); } //update-end---author:GHT ---date:20260604 for:【钉钉Stream回调】拉取实例详情后精准执行节点回调----- // ==================== bpms_task_change ==================== //update-begin---author:GHT ---date:20260604 for:【钉钉Stream回调】节点通过时按operationRecords索引执行onNodeApprove----- - /** - * 节点任务变更:每个审批人操作时触发。 - *

- * 节点通过时: - *

    - *
  1. 拉取审批实例详情,从 operationRecords 得到"已完成任务列表";
  2. - *
  3. 最后一条完成操作的索引 = 本次节点在 MES 流程中的位置;
  4. - *
  5. 用该操作人的 Token 执行对应 MES 节点的 onNodeApprove 回调接口;
  6. - *
  7. 触发 IApprovalBizCallback.onNodeApproved。
  8. - *
- */ public void onTaskChange(JSONObject data) { - if (data == null) return; + if (data == null) { + log.warn("{} bpms_task_change data=null,跳过", LOG_TAG); + return; + } String processInstanceId = data.getString("processInstanceId"); String type = data.getString("type"); String result = data.getString("result"); String actionerDtUserId = data.getString("actionerUserId"); - log.info("[DingBpms] bpms_task_change instanceId={} type={} result={} actionerUserId={}", - processInstanceId, type, result, actionerDtUserId); + log.info("{} bpms_task_change 入参 instanceId={} type={} result={} actionerUserId={} payload={}", + LOG_TAG, processInstanceId, type, result, actionerDtUserId, data.toJSONString()); - // 只处理节点"完成-通过" if (!"finish".equals(type) || !"agree".equals(result)) { - // 拒绝终态由 bpms_instance_change 统一处理,此处不重复触发 - return; - } - MesXslApprovalRecord record = findRecord(processInstanceId); - if (record == null || oConvertUtils.isEmpty(record.getBizTable())) { + log.info("{} bpms_task_change 跳过:非节点通过(finish+agree) type={} result={} instanceId={}", + LOG_TAG, type, result, processInstanceId); return; } - // 拉取实例详情 + MesXslApprovalRecord record = findRecord(processInstanceId); + if (record == null) { + log.warn("{} bpms_task_change 跳过:未找到台账 instanceId={}", LOG_TAG, processInstanceId); + return; + } + if (oConvertUtils.isEmpty(record.getBizTable())) { + log.warn("{} bpms_task_change 跳过:台账无 bizTable recordId={} instanceId={}", + LOG_TAG, record.getId(), processInstanceId); + return; + } + + log.info("{} 台账命中 recordId={} bizTable={} bizDataId={} flowId={} processedOpCount={} currentBizStatus={}", + LOG_TAG, record.getId(), record.getBizTable(), record.getBizDataId(), + record.getFlowId(), record.getProcessedOpCount(), readBizStatus(record)); + JSONObject instance = workflowService.getProcessInstance(processInstanceId); if (instance == null) { - log.warn("[DingBpms] 获取审批实例详情失败 instanceId={},跳过节点回调", processInstanceId); + log.warn("{} bpms_task_change 跳过:获取审批实例详情失败 instanceId={}", LOG_TAG, processInstanceId); return; } List taskOps = workflowService.getTaskOperations(instance); List mesNodes = loadApproverNodes(record.getFlowId()); - if (taskOps.isEmpty() || mesNodes.isEmpty()) { + log.info("{} 实例解析 instanceId={} taskOpCount={} mesApproverNodeCount={} mesNodeNames={} taskOps={}", + LOG_TAG, processInstanceId, taskOps.size(), mesNodes.size(), + summarizeNodeNames(mesNodes), summarizeTaskOps(taskOps)); + + if (taskOps.isEmpty()) { + log.info("{} bpms_task_change 跳过:taskOps 为空 instanceId={}", LOG_TAG, processInstanceId); + return; + } + if (mesNodes.isEmpty()) { + log.info("{} bpms_task_change 跳过:MES 审批节点为空 flowId={} instanceId={}", + LOG_TAG, record.getFlowId(), processInstanceId); return; } - // 刚完成的是最后一条操作(index = taskOps.size()-1) int nodeIndex = taskOps.size() - 1; if (nodeIndex >= mesNodes.size()) { - log.debug("[DingBpms] 节点索引 {} 超出 MES 节点数 {},跳过", nodeIndex, mesNodes.size()); + log.info("{} bpms_task_change 跳过:节点索引越界 nodeIndex={} mesNodeCount={} instanceId={}", + LOG_TAG, nodeIndex, mesNodes.size(), processInstanceId); return; } //update-begin---author:GHT ---date:2026-06-04 for:【20260604】钉钉回调幂等去重:DB乐观锁推进processed_op_count,并发安全且重启不丢----- - // tryMarkNodeProcessed:UPDATE ... SET processed_op_count=nodeIndex+1 WHERE processed_op_count list = approvalRecordService.list( new LambdaQueryWrapper() .eq(MesXslApprovalRecord::getExternalInstanceId, processInstanceId) @@ -271,19 +353,27 @@ public class DingBpmsEventProcessor { private List loadApproverNodes(String flowId) { List result = new ArrayList<>(); - if (oConvertUtils.isEmpty(flowId)) return result; + if (oConvertUtils.isEmpty(flowId)) { + log.info("{} 加载流程节点跳过:flowId 为空", LOG_TAG); + return result; + } try { MesXslApprovalFlow flow = approvalFlowService.getById(flowId); - if (flow == null || oConvertUtils.isEmpty(flow.getFlowConfig())) return result; + if (flow == null || oConvertUtils.isEmpty(flow.getFlowConfig())) { + log.info("{} 加载流程节点跳过:流程不存在或无 flowConfig flowId={}", LOG_TAG, flowId); + return result; + } collectApproverNodes(JSONObject.parseObject(flow.getFlowConfig()), result); } catch (Exception e) { - log.warn("[DingBpms] 加载流程节点失败 flowId={}: {}", flowId, e.getMessage()); + log.warn("{} 加载流程节点失败 flowId={}: {}", LOG_TAG, flowId, e.getMessage()); } return result; } private void collectApproverNodes(JSONObject node, List out) { - if (node == null) return; + if (node == null) { + return; + } if ("approver".equals(node.getString("type"))) { out.add(node); } @@ -297,7 +387,6 @@ public class DingBpmsEventProcessor { collectApproverNodes(node.getJSONObject("childNode"), out); } - /** 找第一条 result=REFUSE 的操作记录 */ private JSONObject findRefuseOp(List taskOps) { for (JSONObject op : taskOps) { String r = op.getString("result"); @@ -308,16 +397,10 @@ public class DingBpmsEventProcessor { return null; } - /** - * 判断业务单据是否仍处于发起审批前的原始状态。 - * 与 MesXslApprovalHandleServiceImpl.isBizAtOriginStatus(MesXslApprovalInstance) 逻辑完全一致, - * 此处基于 MesXslApprovalRecord(钉钉通道)实现,使驳回逻辑在两通道间真正复用。 - */ private boolean isBizAtOriginStatus(MesXslApprovalRecord record) { String statusField = record.getStatusField(); String originStatus = record.getOriginStatus(); if (oConvertUtils.isEmpty(statusField) || originStatus == null) { - // 无快照信息(旧数据兼容):退化为 false,走 onReject 调用 return false; } if (!record.getBizTable().matches("^[A-Za-z0-9_]+$") @@ -327,14 +410,35 @@ public class DingBpmsEventProcessor { try { List vals = jdbcTemplate.queryForList( "SELECT " + statusField + " FROM " + record.getBizTable() - + " WHERE id=? LIMIT 1", String.class, record.getBizDataId()); + + " WHERE id=? LIMIT 1", String.class, record.getBizDataId()); return !vals.isEmpty() && java.util.Objects.equals(originStatus, vals.get(0)); } catch (Exception e) { - log.warn("[DingBpms] 读取业务状态失败 table={}: {}", record.getBizTable(), e.getMessage()); + log.warn("{} 读取业务状态失败 table={} id={}: {}", + LOG_TAG, record.getBizTable(), record.getBizDataId(), e.getMessage()); return false; } } + private String readBizStatus(MesXslApprovalRecord record) { + if (record == null || oConvertUtils.isEmpty(record.getBizTable()) || oConvertUtils.isEmpty(record.getBizDataId())) { + return null; + } + String statusField = oConvertUtils.isEmpty(record.getStatusField()) ? "status" : record.getStatusField(); + if (!record.getBizTable().matches("^[A-Za-z0-9_]+$") || !statusField.matches("^[A-Za-z0-9_]+$")) { + return null; + } + try { + List vals = jdbcTemplate.queryForList( + "SELECT " + statusField + " FROM " + record.getBizTable() + + " WHERE id=? LIMIT 1", String.class, record.getBizDataId()); + return vals.isEmpty() ? null : vals.get(0); + } catch (Exception e) { + log.warn("{} 读取业务当前状态失败 table={} id={}: {}", + LOG_TAG, record.getBizTable(), record.getBizDataId(), e.getMessage()); + return null; + } + } + private ApprovalCallbackContext buildContext(MesXslApprovalRecord record, String comment) { return new ApprovalCallbackContext() .setInstanceId(record.getId()) @@ -349,4 +453,35 @@ public class DingBpmsEventProcessor { .setOperatorUsername("dingtalk") .setOperatorName("钉钉审批"); } + + //update-begin---author:GHT ---date:20260605 for:【XSLMES-20260605-K8R2】钉钉回调统一日志辅助----------- + private void logCallbackDispatch(String action, ApprovalCallbackContext ctx) { + if (ctx == null) { + log.info("{} 触发业务回调 action={} ctx=null", LOG_TAG, action); + return; + } + log.info("{} 触发业务回调 action={} recordId={} bizTable={} bizDataId={} nodeId={} nodeName={} operator={} comment={}", + LOG_TAG, action, ctx.getInstanceId(), ctx.getBizTable(), ctx.getBizDataId(), + ctx.getNodeId(), ctx.getNodeName(), ctx.getOperatorName(), ctx.getComment()); + } + + private String summarizeNodeNames(List mesNodes) { + if (mesNodes == null || mesNodes.isEmpty()) { + return "[]"; + } + return mesNodes.stream() + .map(n -> n.getString("name")) + .collect(Collectors.joining(" -> ", "[", "]")); + } + + private String summarizeTaskOps(List 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】钉钉回调统一日志辅助----------- } diff --git a/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/dingtalk/stream/DingTalkStreamClient.java b/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/dingtalk/stream/DingTalkStreamClient.java index f0859c7b..c6b9cef2 100644 --- a/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/dingtalk/stream/DingTalkStreamClient.java +++ b/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/dingtalk/stream/DingTalkStreamClient.java @@ -1,10 +1,5 @@ package org.jeecg.modules.xslmes.dingtalk.stream; -import com.dingtalk.open.app.api.GenericEventListener; -import com.dingtalk.open.app.api.OpenDingTalkStreamClientBuilder; -import com.dingtalk.open.app.api.message.GenericOpenDingTalkEvent; -import com.dingtalk.open.app.api.security.AuthClientCredential; -import com.dingtalk.open.app.stream.protocol.event.EventAckStatus; import lombok.extern.slf4j.Slf4j; import org.jeecg.common.util.oConvertUtils; import org.jeecg.modules.system.service.impl.ThirdAppDingtalkServiceImpl; @@ -19,6 +14,7 @@ import org.springframework.stereotype.Component; * 官方 SDK 内部自动维护重连与心跳。 *

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

+ * 与 {@link DingTalkStreamClient} 分离:LiteFlow 在上下文初始化早期会扫描所有 {@code @Component} + * 并调用 {@code getDeclaredMethods()},若 Bean 类字节码直接引用钉钉 SDK 类型,会提前加载 + * {@code DingTalkCredential} 等类;本类不参与 Spring 扫描,仅在后台线程中按需加载。 + * + * @author GHT + * @date 2026-06-05 for:【钉钉Stream回调】隔离SDK类避免LiteFlow启动期加载失败 + */ +@Slf4j +public final class DingTalkStreamSdkRunner { + + /** 统一日志前缀,便于 grep:钉钉回调 */ + public static final String LOG_TAG = "[钉钉回调]"; + + private DingTalkStreamSdkRunner() { + } + + //update-begin---author:GHT ---date:2026-06-05 for:【钉钉Stream回调】将SDK启动逻辑从Spring Bean中剥离----- + /** + * 建立钉钉 Stream 长连接并开始接收事件。 + * + * @param appKey 钉钉 AppKey + * @param appSecret 钉钉 AppSecret + * @param processor 审批事件处理器 + */ + public static void start(String appKey, String appSecret, DingBpmsEventProcessor processor) throws Exception { + log.info("{} Stream 正在建连 AppKey={}", LOG_TAG, appKey); + + OpenDingTalkStreamClientBuilder + .custom() + .credential(new AuthClientCredential(appKey, appSecret)) + .registerAllEventListener(new GenericEventListener() { + @Override + public EventAckStatus onEvent(GenericOpenDingTalkEvent event) { + String eventType = event != null ? event.getEventType() : null; + String eventId = event != null ? event.getEventId() : null; + Long bornTime = event != null ? event.getEventBornTime() : null; + long startMs = System.currentTimeMillis(); + try { + com.alibaba.fastjson2.JSONObject data = event != null ? toJsonObject(event.getData()) : null; + //update-begin---author:GHT ---date:20260605 for:【XSLMES-20260605-K8R2】钉钉Stream入站全量日志----------- + log.info("{} Stream入站 eventId={} eventType={} bornTime={} data={}", + LOG_TAG, eventId, eventType, bornTime, + data != null ? data.toJSONString() : "null"); + //update-end---author:GHT ---date:20260605 for:【XSLMES-20260605-K8R2】钉钉Stream入站全量日志----------- + + if (!"bpms_instance_change".equals(eventType) && !"bpms_task_change".equals(eventType)) { + log.info("{} 非审批BPMS事件,已忽略 eventType={}", LOG_TAG, eventType); + return EventAckStatus.SUCCESS; + } + + if (data == null) { + log.warn("{} 事件 data 为空,无法处理 eventType={} eventId={}", LOG_TAG, eventType, eventId); + return EventAckStatus.SUCCESS; + } + + String instanceId = data.getString("processInstanceId"); + log.info("{} 开始处理 eventType={} instanceId={}", LOG_TAG, eventType, instanceId); + + if ("bpms_instance_change".equals(eventType)) { + processor.onInstanceChange(data); + } else { + processor.onTaskChange(data); + } + + log.info("{} 处理完成 eventType={} instanceId={} costMs={}", + LOG_TAG, eventType, instanceId, System.currentTimeMillis() - startMs); + return EventAckStatus.SUCCESS; + + } catch (Exception e) { + log.error("{} 事件处理异常 eventId={} eventType={} costMs={}: {}", + LOG_TAG, eventId, eventType, System.currentTimeMillis() - startMs, + e.getMessage(), e); + return EventAckStatus.LATER; + } + } + }) + .build() + .start(); + + log.info("{} Stream 客户端已启动,等待审批事件推送", LOG_TAG); + } + //update-end---author:GHT ---date:2026-06-05 for:【钉钉Stream回调】将SDK启动逻辑从Spring Bean中剥离----- + + private static com.alibaba.fastjson2.JSONObject toJsonObject(Object raw) { + if (raw == null) { + return null; + } + if (raw instanceof com.alibaba.fastjson2.JSONObject) { + return (com.alibaba.fastjson2.JSONObject) raw; + } + try { + return com.alibaba.fastjson2.JSONObject.parseObject(String.valueOf(raw)); + } catch (Exception e) { + return null; + } + } +} diff --git a/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/dingtalk/stream/DingTalkWorkflowService.java b/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/dingtalk/stream/DingTalkWorkflowService.java index 377ad60b..34023a9a 100644 --- a/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/dingtalk/stream/DingTalkWorkflowService.java +++ b/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/dingtalk/stream/DingTalkWorkflowService.java @@ -34,6 +34,8 @@ import java.util.List; @Service public class DingTalkWorkflowService { + private static final String LOG_TAG = DingTalkStreamSdkRunner.LOG_TAG; + private static final String PROCESS_INSTANCE_URL = "https://api.dingtalk.com/v1.0/workflow/processInstances"; @@ -60,12 +62,16 @@ public class DingTalkWorkflowService { */ public JSONObject getProcessInstance(String processInstanceId) { if (oConvertUtils.isEmpty(processInstanceId)) { + log.info("{} 拉取审批实例跳过:processInstanceId 为空", LOG_TAG); return null; } + //update-begin---author:GHT ---date:20260605 for:【XSLMES-20260605-K8R2】钉钉回调拉取实例详情全量日志----------- + log.info("{} 开始拉取审批实例详情 instanceId={}", LOG_TAG, processInstanceId); + long startMs = System.currentTimeMillis(); // 后台线程无 TenantContext,必须用绕过租户校验的专用方法 String accessToken = dingtalkService.getAccessTokenForBackground(); if (oConvertUtils.isEmpty(accessToken)) { - log.warn("[DingWorkflow] AccessToken 获取失败,无法查询审批实例 {}", processInstanceId); + log.warn("{} AccessToken 获取失败,无法查询审批实例 instanceId={}", LOG_TAG, processInstanceId); return null; } try { @@ -81,16 +87,29 @@ public class DingTalkWorkflowService { String body = client.send(req, HttpResponse.BodyHandlers.ofString()).body(); JSONObject resp = JSONObject.parseObject(body); if (resp.containsKey("code")) { - log.warn("[DingWorkflow] 查询审批实例失败 instanceId={} code={} msg={}", - processInstanceId, resp.getString("code"), resp.getString("message")); + log.warn("{} 查询审批实例失败 instanceId={} code={} msg={} costMs={}", + LOG_TAG, processInstanceId, resp.getString("code"), resp.getString("message"), + System.currentTimeMillis() - startMs); return null; } - return resp.getJSONObject("result"); + JSONObject result = resp.getJSONObject("result"); + if (result == null) { + log.warn("{} 审批实例 result 为空 instanceId={} costMs={}", LOG_TAG, processInstanceId, + System.currentTimeMillis() - startMs); + return null; + } + JSONArray opRecords = result.getJSONArray("operationRecords"); + int opCount = opRecords == null ? 0 : opRecords.size(); + log.info("{} 拉取审批实例成功 instanceId={} status={} result={} operationRecords={} costMs={}", + LOG_TAG, processInstanceId, result.getString("status"), result.getString("result"), + opCount, System.currentTimeMillis() - startMs); + return result; } catch (Exception e) { - log.error("[DingWorkflow] 调用钉钉审批实例详情接口异常 instanceId={}: {}", - processInstanceId, e.getMessage(), e); + log.error("{} 调用钉钉审批实例详情接口异常 instanceId={} costMs={}: {}", + LOG_TAG, processInstanceId, System.currentTimeMillis() - startMs, e.getMessage(), e); return null; } + //update-end---author:GHT ---date:20260605 for:【XSLMES-20260605-K8R2】钉钉回调拉取实例详情全量日志----------- } //update-end---author:GHT ---date:20260604 for:【钉钉Stream回调】拉取钉钉审批实例详情----- @@ -106,10 +125,13 @@ public class DingTalkWorkflowService { public List getTaskOperations(JSONObject instanceResult) { List ops = new ArrayList<>(); if (instanceResult == null) { + log.info("{} 解析 operationRecords 跳过:instanceResult=null", LOG_TAG); return ops; } + String instanceId = instanceResult.getString("processInstanceId"); JSONArray records = instanceResult.getJSONArray("operationRecords"); if (records == null || records.isEmpty()) { + log.info("{} 解析 operationRecords 为空 instanceId={}", LOG_TAG, instanceId); return ops; } for (int i = 0; i < records.size(); i++) { @@ -121,6 +143,10 @@ public class DingTalkWorkflowService { ops.add(rec); } } + //update-begin---author:GHT ---date:20260605 for:【XSLMES-20260605-K8R2】钉钉回调解析节点操作序列日志----------- + log.info("{} 解析 operationRecords instanceId={} rawCount={} taskOpCount={}", + LOG_TAG, instanceId, records.size(), ops.size()); + //update-end---author:GHT ---date:20260605 for:【XSLMES-20260605-K8R2】钉钉回调解析节点操作序列日志----------- return ops; } //update-end---author:GHT ---date:20260604 for:【钉钉Stream回调】从operationRecords提取节点操作序列----- @@ -141,6 +167,7 @@ public class DingTalkWorkflowService { */ public String generateTokenByDtUserId(String dtUserId) { if (oConvertUtils.isEmpty(dtUserId)) { + log.info("{} 生成操作人Token跳过:dtUserId 为空", LOG_TAG); return null; } try { @@ -150,17 +177,21 @@ public class DingTalkWorkflowService { "WHERE third_type='dingtalk' AND third_user_id=? AND (del_flag=0 OR del_flag IS NULL) LIMIT 1", String.class, dtUserId); if (userIds.isEmpty() || oConvertUtils.isEmpty(userIds.get(0))) { - log.debug("[DingWorkflow] 钉钉用户 {} 未绑定 MES 账号,降级使用 admin token", dtUserId); + log.info("{} 钉钉用户未绑定MES账号,降级admin token dtUserId={}", LOG_TAG, dtUserId); return generateAdminToken(); } // ② sys_user_id → username + password SysUser user = sysUserService.getById(userIds.get(0)); if (user == null || oConvertUtils.isEmpty(user.getPassword())) { + log.info("{} 绑定用户无效,降级admin token dtUserId={} sysUserId={}", LOG_TAG, dtUserId, userIds.get(0)); return generateAdminToken(); } + //update-begin---author:GHT ---date:20260605 for:【XSLMES-20260605-K8R2】钉钉回调操作人Token映射日志----------- + log.info("{} 生成操作人Token成功 dtUserId={} mesUsername={}", LOG_TAG, dtUserId, user.getUsername()); + //update-end---author:GHT ---date:20260605 for:【XSLMES-20260605-K8R2】钉钉回调操作人Token映射日志----------- return signAndCache(user.getUsername(), user.getPassword()); } catch (Exception e) { - log.warn("[DingWorkflow] 生成用户 token 失败 dtUserId={}: {}", dtUserId, e.getMessage()); + log.warn("{} 生成用户Token失败,降级admin dtUserId={}: {}", LOG_TAG, dtUserId, e.getMessage()); return generateAdminToken(); } } @@ -170,12 +201,13 @@ public class DingTalkWorkflowService { try { SysUser admin = sysUserService.getUserByName("admin"); if (admin == null || oConvertUtils.isEmpty(admin.getPassword())) { - log.warn("[DingWorkflow] admin 用户不存在,无法生成系统 token"); + log.warn("{} admin 用户不存在,无法生成系统 token", LOG_TAG); return null; } + log.info("{} 使用 admin 兜底Token", LOG_TAG); return signAndCache(admin.getUsername(), admin.getPassword()); } catch (Exception e) { - log.warn("[DingWorkflow] 生成 admin token 失败: {}", e.getMessage()); + log.warn("{} 生成 admin token 失败: {}", LOG_TAG, e.getMessage()); return null; } } diff --git a/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/service/impl/MesXslMixerPsCompileServiceImpl.java b/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/service/impl/MesXslMixerPsCompileServiceImpl.java index 5c86c0c9..78d3b2e8 100644 --- a/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/service/impl/MesXslMixerPsCompileServiceImpl.java +++ b/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/service/impl/MesXslMixerPsCompileServiceImpl.java @@ -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 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 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 ServiceImpljeecg-module-xslmes ${jeecgboot.version} + + + com.dingtalk.open + dingtalk-stream + 1.3.12 + org.jeecgframework.boot3 jeecg-module-device-sync diff --git a/jeecg-boot/jeecg-module-system/jeecg-system-start/src/main/resources/flyway/sql/mysql/V3.9.2_130__mes_xsl_integration_tables.sql b/jeecg-boot/jeecg-module-system/jeecg-system-start/src/main/resources/flyway/sql/mysql/V3.9.2_130__mes_xsl_integration_tables.sql new file mode 100644 index 00000000..28a4ee2e --- /dev/null +++ b/jeecg-boot/jeecg-module-system/jeecg-system-start/src/main/resources/flyway/sql/mysql/V3.9.2_130__mes_xsl_integration_tables.sql @@ -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; diff --git a/jeecg-boot/jeecg-module-system/jeecg-system-start/src/main/resources/flyway/sql/mysql/V3.9.2_131__mes_xsl_integration_dict.sql b/jeecg-boot/jeecg-module-system/jeecg-system-start/src/main/resources/flyway/sql/mysql/V3.9.2_131__mes_xsl_integration_dict.sql new file mode 100644 index 00000000..bed1a28b --- /dev/null +++ b/jeecg-boot/jeecg-module-system/jeecg-system-start/src/main/resources/flyway/sql/mysql/V3.9.2_131__mes_xsl_integration_dict.sql @@ -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()); diff --git a/jeecg-boot/jeecg-module-system/jeecg-system-start/src/main/resources/flyway/sql/mysql/V3.9.2_132__mes_xsl_integration_menu.sql b/jeecg-boot/jeecg-module-system/jeecg-system-start/src/main/resources/flyway/sql/mysql/V3.9.2_132__mes_xsl_integration_menu.sql new file mode 100644 index 00000000..6b8aae2f --- /dev/null +++ b/jeecg-boot/jeecg-module-system/jeecg-system-start/src/main/resources/flyway/sql/mysql/V3.9.2_132__mes_xsl_integration_menu.sql @@ -0,0 +1,50 @@ +-- 【审核集成 Phase0】菜单 + 权限(挂在 MESToDing审批配置 父菜单 178046026420801 下) +-- author: GHT date: 2026-06-05 +SET NAMES utf8mb4; + +-- 单据注册中心 +INSERT INTO sys_permission(id,parent_id,name,url,component,component_name,redirect,menu_type,perms,perms_type,sort_no,always_show,icon,is_route,is_leaf,keep_alive,hidden,hide_tab,description,status,del_flag,rule_flag,create_by,create_time,update_by,update_time,internal_or_external) +VALUES('178046026420838','178046026420801','单据注册中心','/xslmes/mesXslBizDocRegistryList','xslmes/approval/integration/MesXslBizDocRegistryList',NULL,NULL,0,NULL,'1',4.00,0,'ant-design:database-outlined',1,0,0,0,0,NULL,'1',0,0,'admin','2026-06-05 00:00:00',NULL,NULL,0); + +INSERT INTO sys_permission(id,parent_id,name,url,component,is_route,component_name,redirect,menu_type,perms,perms_type,sort_no,always_show,icon,is_leaf,keep_alive,hidden,hide_tab,description,create_by,create_time,update_by,update_time,del_flag,rule_flag,status,internal_or_external) +VALUES('178046026420839','178046026420838','查询单据注册',NULL,NULL,0,NULL,NULL,2,'xslmes:mes_xsl_biz_doc_registry:list','1',NULL,0,NULL,1,0,0,0,NULL,'admin','2026-06-05 00:00:00',NULL,NULL,0,0,'1',0); +INSERT INTO sys_permission(id,parent_id,name,url,component,is_route,component_name,redirect,menu_type,perms,perms_type,sort_no,always_show,icon,is_leaf,keep_alive,hidden,hide_tab,description,create_by,create_time,update_by,update_time,del_flag,rule_flag,status,internal_or_external) +VALUES('178046026420840','178046026420838','新增单据注册',NULL,NULL,0,NULL,NULL,2,'xslmes:mes_xsl_biz_doc_registry:add','1',NULL,0,NULL,1,0,0,0,NULL,'admin','2026-06-05 00:00:00',NULL,NULL,0,0,'1',0); +INSERT INTO sys_permission(id,parent_id,name,url,component,is_route,component_name,redirect,menu_type,perms,perms_type,sort_no,always_show,icon,is_leaf,keep_alive,hidden,hide_tab,description,create_by,create_time,update_by,update_time,del_flag,rule_flag,status,internal_or_external) +VALUES('178046026420841','178046026420838','编辑单据注册',NULL,NULL,0,NULL,NULL,2,'xslmes:mes_xsl_biz_doc_registry:edit','1',NULL,0,NULL,1,0,0,0,NULL,'admin','2026-06-05 00:00:00',NULL,NULL,0,0,'1',0); +INSERT INTO sys_permission(id,parent_id,name,url,component,is_route,component_name,redirect,menu_type,perms,perms_type,sort_no,always_show,icon,is_leaf,keep_alive,hidden,hide_tab,description,create_by,create_time,update_by,update_time,del_flag,rule_flag,status,internal_or_external) +VALUES('178046026420842','178046026420838','删除单据注册',NULL,NULL,0,NULL,NULL,2,'xslmes:mes_xsl_biz_doc_registry:delete','1',NULL,0,NULL,1,0,0,0,NULL,'admin','2026-06-05 00:00:00',NULL,NULL,0,0,'1',0); + +-- 集成方案管理(含内嵌动作管理) +INSERT INTO sys_permission(id,parent_id,name,url,component,component_name,redirect,menu_type,perms,perms_type,sort_no,always_show,icon,is_route,is_leaf,keep_alive,hidden,hide_tab,description,status,del_flag,rule_flag,create_by,create_time,update_by,update_time,internal_or_external) +VALUES('178046026420830','178046026420801','集成方案管理','/xslmes/mesXslIntegrationPlanList','xslmes/approval/integration/MesXslIntegrationPlanList',NULL,NULL,0,NULL,'1',5.00,0,'ant-design:node-index-outlined',1,0,0,0,0,NULL,'1',0,0,'admin','2026-06-05 00:00:00',NULL,NULL,0); + +INSERT INTO sys_permission(id,parent_id,name,url,component,is_route,component_name,redirect,menu_type,perms,perms_type,sort_no,always_show,icon,is_leaf,keep_alive,hidden,hide_tab,description,create_by,create_time,update_by,update_time,del_flag,rule_flag,status,internal_or_external) +VALUES('178046026420831','178046026420830','查询集成方案',NULL,NULL,0,NULL,NULL,2,'xslmes:mes_xsl_integration_plan:list','1',NULL,0,NULL,1,0,0,0,NULL,'admin','2026-06-05 00:00:00',NULL,NULL,0,0,'1',0); +INSERT INTO sys_permission(id,parent_id,name,url,component,is_route,component_name,redirect,menu_type,perms,perms_type,sort_no,always_show,icon,is_leaf,keep_alive,hidden,hide_tab,description,create_by,create_time,update_by,update_time,del_flag,rule_flag,status,internal_or_external) +VALUES('178046026420832','178046026420830','新增集成方案',NULL,NULL,0,NULL,NULL,2,'xslmes:mes_xsl_integration_plan:add','1',NULL,0,NULL,1,0,0,0,NULL,'admin','2026-06-05 00:00:00',NULL,NULL,0,0,'1',0); +INSERT INTO sys_permission(id,parent_id,name,url,component,is_route,component_name,redirect,menu_type,perms,perms_type,sort_no,always_show,icon,is_leaf,keep_alive,hidden,hide_tab,description,create_by,create_time,update_by,update_time,del_flag,rule_flag,status,internal_or_external) +VALUES('178046026420833','178046026420830','编辑集成方案',NULL,NULL,0,NULL,NULL,2,'xslmes:mes_xsl_integration_plan:edit','1',NULL,0,NULL,1,0,0,0,NULL,'admin','2026-06-05 00:00:00',NULL,NULL,0,0,'1',0); +INSERT INTO sys_permission(id,parent_id,name,url,component,is_route,component_name,redirect,menu_type,perms,perms_type,sort_no,always_show,icon,is_leaf,keep_alive,hidden,hide_tab,description,create_by,create_time,update_by,update_time,del_flag,rule_flag,status,internal_or_external) +VALUES('178046026420834','178046026420830','删除集成方案',NULL,NULL,0,NULL,NULL,2,'xslmes:mes_xsl_integration_plan:delete','1',NULL,0,NULL,1,0,0,0,NULL,'admin','2026-06-05 00:00:00',NULL,NULL,0,0,'1',0); +INSERT INTO sys_permission(id,parent_id,name,url,component,is_route,component_name,redirect,menu_type,perms,perms_type,sort_no,always_show,icon,is_leaf,keep_alive,hidden,hide_tab,description,create_by,create_time,update_by,update_time,del_flag,rule_flag,status,internal_or_external) +VALUES('178046026420843','178046026420830','发布集成方案',NULL,NULL,0,NULL,NULL,2,'xslmes:mes_xsl_integration_plan:publish','1',NULL,0,NULL,1,0,0,0,NULL,'admin','2026-06-05 00:00:00',NULL,NULL,0,0,'1',0); + +-- 集成执行日志 +INSERT INTO sys_permission(id,parent_id,name,url,component,component_name,redirect,menu_type,perms,perms_type,sort_no,always_show,icon,is_route,is_leaf,keep_alive,hidden,hide_tab,description,status,del_flag,rule_flag,create_by,create_time,update_by,update_time,internal_or_external) +VALUES('178046026420835','178046026420801','集成执行日志','/xslmes/mesXslIntegrationLogList','xslmes/approval/integration/MesXslIntegrationLogList',NULL,NULL,0,NULL,'1',6.00,0,'ant-design:file-search-outlined',1,0,0,0,0,NULL,'1',0,0,'admin','2026-06-05 00:00:00',NULL,NULL,0); + +INSERT INTO sys_permission(id,parent_id,name,url,component,is_route,component_name,redirect,menu_type,perms,perms_type,sort_no,always_show,icon,is_leaf,keep_alive,hidden,hide_tab,description,create_by,create_time,update_by,update_time,del_flag,rule_flag,status,internal_or_external) +VALUES('178046026420836','178046026420835','查询集成日志',NULL,NULL,0,NULL,NULL,2,'xslmes:mes_xsl_integration_log:list','1',NULL,0,NULL,1,0,0,0,NULL,'admin','2026-06-05 00:00:00',NULL,NULL,0,0,'1',0); +INSERT INTO sys_permission(id,parent_id,name,url,component,is_route,component_name,redirect,menu_type,perms,perms_type,sort_no,always_show,icon,is_leaf,keep_alive,hidden,hide_tab,description,create_by,create_time,update_by,update_time,del_flag,rule_flag,status,internal_or_external) +VALUES('178046026420837','178046026420835','重试集成动作',NULL,NULL,0,NULL,NULL,2,'xslmes:mes_xsl_integration_log:retry','1',NULL,0,NULL,1,0,0,0,NULL,'admin','2026-06-05 00:00:00',NULL,NULL,0,0,'1',0); + +-- 授权给超管角色(f6817f48af4fb3af11b9e8bf182f618b) +INSERT INTO sys_role_permission (id,role_id,permission_id,data_rule_ids,operate_date,operate_ip) +SELECT REPLACE(UUID(),'-',''),'f6817f48af4fb3af11b9e8bf182f618b',id,NULL,'2026-06-05 00:00:00','127.0.0.1' +FROM sys_permission +WHERE id IN ( + '178046026420830','178046026420831','178046026420832','178046026420833','178046026420834','178046026420843', + '178046026420835','178046026420836','178046026420837', + '178046026420838','178046026420839','178046026420840','178046026420841','178046026420842' +); diff --git a/jeecg-boot/jeecg-module-system/jeecg-system-start/src/main/resources/flyway/sql/mysql/V3.9.2_133__mes_xsl_integration_demo_mixerps.sql b/jeecg-boot/jeecg-module-system/jeecg-system-start/src/main/resources/flyway/sql/mysql/V3.9.2_133__mes_xsl_integration_demo_mixerps.sql new file mode 100644 index 00000000..7767e456 --- /dev/null +++ b/jeecg-boot/jeecg-module-system/jeecg-system-start/src/main/resources/flyway/sql/mysql/V3.9.2_133__mes_xsl_integration_demo_mixerps.sql @@ -0,0 +1,106 @@ +-- 【审核集成 Phase0】密炼PS编制 — 集成方案演示数据 +-- 功能说明: +-- 将密炼PS现有三节点审批(校对→审核→批准)的状态流转逻辑, +-- 转化为可视化的集成方案配置,验证 SQL_UPDATE 动作执行器的实际效果。 +-- 注意: +-- 方案初始状态为"草稿(0)",不会自动触发。 +-- 需要在【集成方案管理】页面手动"发布"后才生效。 +-- 现有 @ApprovalBizAction HTTP 回调仍然生效(两套并行,SQL 以 AND status=? 条件防重)。 +-- author: GHT date: 2026-06-05 +SET NAMES utf8mb4; + +-- ============================================================= +-- 方案一:审批全部通过 — 推进主表 + 配合示方到最终批准态 +-- 触发时机:onApprove(最终通过整个流程时) +-- ============================================================= +INSERT IGNORE INTO `mes_xsl_integration_plan` + (`id`,`plan_code`,`plan_name`,`source_table`,`trigger_phase`,`exec_mode`,`match_condition`,`status`,`remark`,`del_flag`,`tenant_id`,`create_by`,`create_time`) +VALUES + ('mxpsonapproveplan000001','mixer_ps_on_approve','密炼PS-审批通过→批准态同步','mes_xsl_mixer_ps_compile','onApprove','async',NULL,'0', + '最终审批通过后:主表 status→approve,配合示方 status→recognition_pass', + 0,0,'admin',NOW()); + +INSERT IGNORE INTO `mes_xsl_integration_action` + (`id`,`plan_id`,`action_name`,`action_type`,`sql_template`,`exec_order`,`on_fail`,`enabled`,`remark`,`del_flag`,`create_by`,`create_time`) +VALUES + ('mxpsonapprove_act001','mxpsonapproveplan000001', + '主表→批准态','SQL_UPDATE', + 'UPDATE mes_xsl_mixer_ps_compile SET status=''approve'', approve_time=NOW() WHERE id=#{source.id} AND status=''audit''', + 1,'stop',1,'只在 audit 态才更新,防止重复触发',0,'admin',NOW()), + + ('mxpsonapprove_act002','mxpsonapproveplan000001', + '配合示方→认定通过','SQL_UPDATE', + 'UPDATE mes_xsl_formula_spec SET status=''recognition_pass'', approve_time=NOW() WHERE issue_number=#{source.issue_number} AND issue_number IS NOT NULL', + 2,'continue',1,'通过 issue_number 级联同步,issue_number 为空时影响0行自动跳过',0,'admin',NOW()), + + ('mxpsonapprove_act003','mxpsonapproveplan000001', + '混炼示方→同步批准时间','SQL_UPDATE', + 'UPDATE mes_xsl_mixing_spec SET approve_time=NOW() WHERE issue_number=#{source.issue_number} AND issue_number IS NOT NULL', + 3,'continue',1,'仅同步时间戳,不改变混炼示方 status',0,'admin',NOW()); + +-- ============================================================= +-- 方案二:审批驳回 — 全量回退到编制态,清空所有痕迹 +-- 触发时机:onReject(任一节点被驳回时) +-- ============================================================= +INSERT IGNORE INTO `mes_xsl_integration_plan` + (`id`,`plan_code`,`plan_name`,`source_table`,`trigger_phase`,`exec_mode`,`match_condition`,`status`,`remark`,`del_flag`,`tenant_id`,`create_by`,`create_time`) +VALUES + ('mxpsonrejectplan000002','mixer_ps_on_reject','密炼PS-审批驳回→恢复编制态','mes_xsl_mixer_ps_compile','onReject','async',NULL,'0', + '驳回后:主表+配合示方回到 compile,清空校对/审核/批准全部痕迹字段', + 0,0,'admin',NOW()); + +INSERT IGNORE INTO `mes_xsl_integration_action` + (`id`,`plan_id`,`action_name`,`action_type`,`sql_template`,`exec_order`,`on_fail`,`enabled`,`remark`,`del_flag`,`create_by`,`create_time`) +VALUES + ('mxpsonreject_act001','mxpsonrejectplan000002', + '主表→回退编制态','SQL_UPDATE', + 'UPDATE mes_xsl_mixer_ps_compile SET status=''compile'', proofread_by=NULL, proofread_time=NULL, audit_by=NULL, audit_time=NULL, approve_by=NULL, approve_time=NULL WHERE id=#{source.id}', + 1,'stop',1,'无条件回退,清空所有审批痕迹',0,'admin',NOW()), + + ('mxpsonreject_act002','mxpsonrejectplan000002', + '配合示方→回退编制态','SQL_UPDATE', + 'UPDATE mes_xsl_formula_spec SET status=''compile'', proofread_by=NULL, proofread_time=NULL, audit_by=NULL, audit_time=NULL, approve_by=NULL, approve_time=NULL WHERE issue_number=#{source.issue_number} AND issue_number IS NOT NULL', + 2,'continue',1,'级联回退配合示方状态',0,'admin',NOW()), + + ('mxpsonreject_act003','mxpsonrejectplan000002', + '混炼示方→清空痕迹','SQL_UPDATE', + 'UPDATE mes_xsl_mixing_spec SET proofread_by=NULL, proofread_time=NULL, audit_by=NULL, audit_time=NULL, approve_by=NULL, approve_time=NULL WHERE issue_number=#{source.issue_number} AND issue_number IS NOT NULL', + 3,'continue',1,'混炼示方只清痕迹不改 status',0,'admin',NOW()); + +-- ============================================================= +-- 方案三:节点逐级通过 — 利用条件 WHERE 区分校对/审核两个节点 +-- 触发时机:onNodeApprove(每通过一个中间节点时触发一次) +-- 关键设计:每个 UPDATE 带 AND status='当前期望状态' 条件, +-- 当 status 不匹配时影响 0 行,自动跳过,实现节点自动识别 +-- ============================================================= +INSERT IGNORE INTO `mes_xsl_integration_plan` + (`id`,`plan_code`,`plan_name`,`source_table`,`trigger_phase`,`exec_mode`,`match_condition`,`status`,`remark`,`del_flag`,`tenant_id`,`create_by`,`create_time`) +VALUES + ('mxpsnodeapprplan000003','mixer_ps_node_approve','密炼PS-节点通过→逐级状态推进','mes_xsl_mixer_ps_compile','onNodeApprove','async',NULL,'0', + '利用条件 WHERE 区分校对/审核两节点:每次只有一条 SQL 真正命中(影响1行),另一条影响0行自动跳过', + 0,0,'admin',NOW()); + +INSERT IGNORE INTO `mes_xsl_integration_action` + (`id`,`plan_id`,`action_name`,`action_type`,`sql_template`,`exec_order`,`on_fail`,`enabled`,`remark`,`del_flag`,`create_by`,`create_time`) +VALUES + -- 第1、2条:校对节点通过(当前 status=compile 时命中) + ('mxpsnodeappr_act001','mxpsnodeapprplan000003', + '校对通过-主表→proofread','SQL_UPDATE', + 'UPDATE mes_xsl_mixer_ps_compile SET status=''proofread'', proofread_time=NOW() WHERE id=#{source.id} AND status=''compile''', + 1,'continue',1,'status=compile 才命中(校对节点);status=proofread 时影响0行自动跳过',0,'admin',NOW()), + + ('mxpsnodeappr_act002','mxpsnodeapprplan000003', + '校对通过-配合示方→submit','SQL_UPDATE', + 'UPDATE mes_xsl_formula_spec SET status=''submit'', proofread_time=NOW() WHERE issue_number=#{source.issue_number} AND status=''compile'' AND issue_number IS NOT NULL', + 2,'continue',1,'配合示方校对态同步',0,'admin',NOW()), + + -- 第3、4条:审核节点通过(当前 status=proofread 时命中) + ('mxpsnodeappr_act003','mxpsnodeapprplan000003', + '审核通过-主表→audit','SQL_UPDATE', + 'UPDATE mes_xsl_mixer_ps_compile SET status=''audit'', audit_time=NOW() WHERE id=#{source.id} AND status=''proofread''', + 3,'continue',1,'status=proofread 才命中(审核节点);校对节点时影响0行跳过',0,'admin',NOW()), + + ('mxpsnodeappr_act004','mxpsnodeapprplan000003', + '审核通过-配合示方→review_pass','SQL_UPDATE', + 'UPDATE mes_xsl_formula_spec SET status=''review_pass'', audit_time=NOW() WHERE issue_number=#{source.issue_number} AND status=''submit'' AND issue_number IS NOT NULL', + 4,'continue',1,'配合示方审核态同步',0,'admin',NOW()); diff --git a/jeecg-boot/jeecg-module-system/jeecg-system-start/src/main/resources/flyway/sql/mysql/V3.9.2_134__mes_xsl_integration_action_config.sql b/jeecg-boot/jeecg-module-system/jeecg-system-start/src/main/resources/flyway/sql/mysql/V3.9.2_134__mes_xsl_integration_action_config.sql new file mode 100644 index 00000000..d27ecf91 --- /dev/null +++ b/jeecg-boot/jeecg-module-system/jeecg-system-start/src/main/resources/flyway/sql/mysql/V3.9.2_134__mes_xsl_integration_action_config.sql @@ -0,0 +1,5 @@ +-- 【审核集成 Phase0】新增动作可视化配置字段 +-- 用于存储可视化编辑器的配置 JSON,支持重新打开时还原可视化状态 +-- author: GHT date: 2026-06-05 +ALTER TABLE `mes_xsl_integration_action` + ADD COLUMN `action_config` TEXT DEFAULT NULL COMMENT '可视化配置JSON(可视化编辑器专用,用于重新打开时还原配置)' AFTER `sql_template`; diff --git a/jeecg-boot/jeecg-module-system/jeecg-system-start/src/main/resources/flyway/sql/mysql/V3.9.2_135__mes_xsl_approval_registry.sql b/jeecg-boot/jeecg-module-system/jeecg-system-start/src/main/resources/flyway/sql/mysql/V3.9.2_135__mes_xsl_approval_registry.sql new file mode 100644 index 00000000..12f681ef --- /dev/null +++ b/jeecg-boot/jeecg-module-system/jeecg-system-start/src/main/resources/flyway/sql/mysql/V3.9.2_135__mes_xsl_approval_registry.sql @@ -0,0 +1,132 @@ +-- 【审批注册中心】扩展注册配置 + 审批痕迹明细表(每单据一行) +-- author: GHT date: 2026-06-05 for:【XSLMES-20260605-K8R2】 +SET NAMES utf8mb4; +SET @db = DATABASE(); + +-- ① 扩展 mes_xsl_biz_doc_registry(幂等:列已存在则跳过) +SET @sql = IF( + (SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = @db AND TABLE_NAME = 'mes_xsl_biz_doc_registry' AND COLUMN_NAME = 'enabled_stages') = 0, + 'ALTER TABLE `mes_xsl_biz_doc_registry` ADD COLUMN `enabled_stages` varchar(128) DEFAULT NULL COMMENT ''启用环节(多选逗号分隔 proofread,audit,approve)'' AFTER `enabled`', + 'SELECT 1' +); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +SET @sql = IF( + (SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = @db AND TABLE_NAME = 'mes_xsl_biz_doc_registry' AND COLUMN_NAME = 'status_field') = 0, + 'ALTER TABLE `mes_xsl_biz_doc_registry` ADD COLUMN `status_field` varchar(64) DEFAULT ''status'' COMMENT ''业务状态字段名'' AFTER `enabled_stages`', + 'SELECT 1' +); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +SET @sql = IF( + (SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = @db AND TABLE_NAME = 'mes_xsl_biz_doc_registry' AND COLUMN_NAME = 'proofread_by_field') = 0, + 'ALTER TABLE `mes_xsl_biz_doc_registry` ADD COLUMN `proofread_by_field` varchar(64) DEFAULT ''proofread_by'' COMMENT ''校对人字段名'' AFTER `status_field`', + 'SELECT 1' +); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +SET @sql = IF( + (SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = @db AND TABLE_NAME = 'mes_xsl_biz_doc_registry' AND COLUMN_NAME = 'proofread_time_field') = 0, + 'ALTER TABLE `mes_xsl_biz_doc_registry` ADD COLUMN `proofread_time_field` varchar(64) DEFAULT ''proofread_time'' COMMENT ''校对时间字段名'' AFTER `proofread_by_field`', + 'SELECT 1' +); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +SET @sql = IF( + (SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = @db AND TABLE_NAME = 'mes_xsl_biz_doc_registry' AND COLUMN_NAME = 'audit_by_field') = 0, + 'ALTER TABLE `mes_xsl_biz_doc_registry` ADD COLUMN `audit_by_field` varchar(64) DEFAULT ''audit_by'' COMMENT ''审核人字段名'' AFTER `proofread_time_field`', + 'SELECT 1' +); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +SET @sql = IF( + (SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = @db AND TABLE_NAME = 'mes_xsl_biz_doc_registry' AND COLUMN_NAME = 'audit_time_field') = 0, + 'ALTER TABLE `mes_xsl_biz_doc_registry` ADD COLUMN `audit_time_field` varchar(64) DEFAULT ''audit_time'' COMMENT ''审核时间字段名'' AFTER `audit_by_field`', + 'SELECT 1' +); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +SET @sql = IF( + (SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = @db AND TABLE_NAME = 'mes_xsl_biz_doc_registry' AND COLUMN_NAME = 'approve_by_field') = 0, + 'ALTER TABLE `mes_xsl_biz_doc_registry` ADD COLUMN `approve_by_field` varchar(64) DEFAULT ''approve_by'' COMMENT ''批准人字段名'' AFTER `audit_time_field`', + 'SELECT 1' +); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +SET @sql = IF( + (SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = @db AND TABLE_NAME = 'mes_xsl_biz_doc_registry' AND COLUMN_NAME = 'approve_time_field') = 0, + 'ALTER TABLE `mes_xsl_biz_doc_registry` ADD COLUMN `approve_time_field` varchar(64) DEFAULT ''approve_time'' COMMENT ''批准时间字段名'' AFTER `approve_by_field`', + 'SELECT 1' +); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +ALTER TABLE `mes_xsl_biz_doc_registry` COMMENT='MES审批注册中心'; + +-- 密炼PS编制默认开启三环节 +UPDATE `mes_xsl_biz_doc_registry` +SET `enabled_stages` = 'proofread,audit,approve', + `status_field` = 'status', + `proofread_by_field` = 'proofread_by', + `proofread_time_field` = 'proofread_time', + `audit_by_field` = 'audit_by', + `audit_time_field` = 'audit_time', + `approve_by_field` = 'approve_by', + `approve_time_field` = 'approve_time', + `update_by` = 'admin', + `update_time` = NOW() +WHERE `doc_code` = 'mixer_ps_compile' AND `del_flag` = 0; + +-- ② 审批痕迹明细(每业务单据一行,按 biz_table + biz_data_id 唯一) +CREATE TABLE IF NOT EXISTS `mes_xsl_approval_trace` ( + `id` varchar(32) NOT NULL COMMENT '主键', + `registry_id` varchar(32) DEFAULT NULL COMMENT '审批注册配置ID', + `biz_table` varchar(128) NOT NULL COMMENT '业务表名', + `biz_data_id` varchar(32) NOT NULL COMMENT '业务单据ID', + `proofread_by` varchar(80) DEFAULT NULL COMMENT '校对人', + `proofread_time` datetime DEFAULT NULL COMMENT '校对时间', + `audit_by` varchar(80) DEFAULT NULL COMMENT '审核人', + `audit_time` datetime DEFAULT NULL COMMENT '审核时间', + `approve_by` varchar(80) DEFAULT NULL COMMENT '批准人', + `approve_time` datetime DEFAULT NULL COMMENT '批准时间', + `remark` varchar(500) DEFAULT NULL COMMENT '备注', + `del_flag` int DEFAULT 0 COMMENT '逻辑删除', + `tenant_id` int DEFAULT NULL COMMENT '租户ID', + `sys_org_code` varchar(64) DEFAULT NULL, + `create_by` varchar(50) DEFAULT NULL, + `create_time` datetime DEFAULT NULL, + `update_by` varchar(50) DEFAULT NULL, + `update_time` datetime DEFAULT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `uk_approval_trace_biz` (`biz_table`, `biz_data_id`, `tenant_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='MES审批痕迹明细(每单据一行)'; + +-- ③ 审批环节字典(注册中心多选) +INSERT IGNORE INTO `sys_dict` (`id`,`dict_name`,`dict_code`,`description`,`del_flag`,`create_by`,`create_time`,`type`,`tenant_id`) +VALUES ('1995000000000000390','审批环节','mes_xsl_approval_stage','审批注册中心可启用环节',0,'admin',NOW(),0,0); + +INSERT IGNORE INTO `sys_dict_item` (`id`,`dict_id`,`item_text`,`item_value`,`description`,`sort_order`,`status`,`create_by`,`create_time`) +VALUES +('1995000000000000391','1995000000000000390','校对','proofread','校对环节',1,1,'admin',NOW()), +('1995000000000000392','1995000000000000390','审核','audit','审核环节',2,1,'admin',NOW()), +('1995000000000000393','1995000000000000390','批准','approve','批准环节',3,1,'admin',NOW()); + +-- ④ 菜单:重命名 + 新增审批痕迹 +UPDATE `sys_permission` +SET `name` = '审批注册中心', `update_by` = 'admin', `update_time` = NOW() +WHERE `id` = '178046026420838'; + +INSERT IGNORE INTO `sys_permission` + (`id`,`parent_id`,`name`,`url`,`component`,`is_route`,`component_name`,`redirect`,`menu_type`,`perms`,`perms_type`,`sort_no`,`always_show`,`icon`,`is_leaf`,`keep_alive`,`hidden`,`hide_tab`,`description`,`status`,`del_flag`,`rule_flag`,`create_by`,`create_time`) +VALUES +('178046026420843','178046026420801','审批痕迹','/xslmes/mesXslApprovalTraceList','xslmes/approval/integration/MesXslApprovalTraceList',1,NULL,0,NULL,1,4.50,0,'ant-design:history-outlined',1,0,0,0,0,NULL,1,0,0,'admin',NOW()); + +INSERT IGNORE INTO `sys_permission` + (`id`,`parent_id`,`name`,`perms`,`perms_type`,`menu_type`,`sort_no`,`is_leaf`,`create_by`,`create_time`) +VALUES +('178046026420844','178046026420843','查询审批痕迹','xslmes:mes_xsl_approval_trace:list',1,2,1,1,'admin',NOW()); + +INSERT IGNORE INTO `sys_role_permission` (`id`,`role_id`,`permission_id`,`operate_date`,`operate_ip`) +VALUES (REPLACE(UUID(),'-',''),'f6817f48af4fb3af11b9e8bf182f618b','178046026420843',NOW(),'127.0.0.1'); + +INSERT IGNORE INTO `sys_role_permission` (`id`,`role_id`,`permission_id`,`operate_date`,`operate_ip`) +VALUES (REPLACE(UUID(),'-',''),'f6817f48af4fb3af11b9e8bf182f618b','178046026420844',NOW(),'127.0.0.1'); diff --git a/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 b/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 new file mode 100644 index 00000000..638d6458 --- /dev/null +++ b/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 @@ -0,0 +1,26 @@ +-- 【审批注册中心】新增「查看明细」按钮权限,并授权给已有注册中心权限的角色 +-- author: GHT date: 2026-06-05 for:【XSLMES-20260605-K8R2】 +SET NAMES utf8mb4; + +-- ① 审批注册中心下新增按钮权限:查看审批明细(列格式与 V3.9.2_132 按钮权限一致) +INSERT IGNORE INTO sys_permission(id,parent_id,name,url,component,is_route,component_name,redirect,menu_type,perms,perms_type,sort_no,always_show,icon,is_leaf,keep_alive,hidden,hide_tab,description,create_by,create_time,update_by,update_time,del_flag,rule_flag,status,internal_or_external) +VALUES('178046026420845','178046026420838','查看审批明细',NULL,NULL,0,NULL,NULL,2,'xslmes:mes_xsl_biz_doc_registry:trace','1',5,0,NULL,1,0,0,0,'审批注册中心操作列查看审批痕迹明细','admin',NOW(),NULL,NULL,0,0,'1',0); + +-- ② 超管角色授权 +INSERT IGNORE INTO `sys_role_permission` (`id`,`role_id`,`permission_id`,`operate_date`,`operate_ip`) +VALUES (REPLACE(UUID(),'-',''),'f6817f48af4fb3af11b9e8bf182f618b','178046026420845',NOW(),'127.0.0.1'); + +-- ③ 已有审批注册中心任意按钮权限的角色,自动补「查看明细」权限 +INSERT IGNORE INTO `sys_role_permission` (`id`,`role_id`,`permission_id`,`operate_date`,`operate_ip`) +SELECT REPLACE(UUID(),'-',''), rp.`role_id`, '178046026420845', NOW(), '127.0.0.1' +FROM `sys_role_permission` rp +WHERE rp.`permission_id` IN ( + '178046026420839', + '178046026420840', + '178046026420841', + '178046026420842' +) +AND NOT EXISTS ( + SELECT 1 FROM `sys_role_permission` x + WHERE x.`role_id` = rp.`role_id` AND x.`permission_id` = '178046026420845' +); diff --git a/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 b/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 new file mode 100644 index 00000000..ea726b49 --- /dev/null +++ b/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 @@ -0,0 +1,32 @@ +-- 【集成方案】与审批注册中心环节绑定 +-- author: GHT date: 2026-06-05 for:【XSLMES-20260605-K8R2】 +SET NAMES utf8mb4; +SET @db = DATABASE(); + +-- ① 集成方案:关联注册中心 + 绑定审批环节 +SET @sql = IF( + (SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = @db AND TABLE_NAME = 'mes_xsl_integration_plan' AND COLUMN_NAME = 'registry_id') = 0, + 'ALTER TABLE `mes_xsl_integration_plan` ADD COLUMN `registry_id` varchar(32) DEFAULT NULL COMMENT ''审批注册中心ID'' AFTER `source_table`', + 'SELECT 1' +); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +SET @sql = IF( + (SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = @db AND TABLE_NAME = 'mes_xsl_integration_plan' AND COLUMN_NAME = 'trigger_stage') = 0, + 'ALTER TABLE `mes_xsl_integration_plan` ADD COLUMN `trigger_stage` varchar(32) DEFAULT NULL COMMENT ''绑定审批环节 proofread/audit/approve'' AFTER `trigger_phase`', + 'SELECT 1' +); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +-- ② 回填已有方案的注册中心与环节(按 source_table 关联) +UPDATE `mes_xsl_integration_plan` p +INNER JOIN `mes_xsl_biz_doc_registry` r ON r.table_name = p.source_table AND r.del_flag = 0 AND r.enabled = 1 +SET p.registry_id = r.id, + p.trigger_stage = CASE p.trigger_phase + WHEN 'onApprove' THEN 'approve' + WHEN 'onNodeApprove' THEN NULL + ELSE NULL + END, + p.update_by = 'admin', + p.update_time = NOW() +WHERE p.del_flag = 0 AND (p.registry_id IS NULL OR p.trigger_stage IS NULL); diff --git a/jeecg-boot/jeecg-module-system/jeecg-system-start/src/main/resources/flyway/sql/mysql/V3.9.2_138__mes_xsl_registry_stage_sync.sql b/jeecg-boot/jeecg-module-system/jeecg-system-start/src/main/resources/flyway/sql/mysql/V3.9.2_138__mes_xsl_registry_stage_sync.sql new file mode 100644 index 00000000..03676888 --- /dev/null +++ b/jeecg-boot/jeecg-module-system/jeecg-system-start/src/main/resources/flyway/sql/mysql/V3.9.2_138__mes_xsl_registry_stage_sync.sql @@ -0,0 +1,87 @@ +-- 【集成方案】审批注册中心环节同步动作 + 密炼PS无代码审批方案示例 +-- author: GHT date: 2026-06-05 for:【XSLMES-20260605-K8R2】 +SET NAMES utf8mb4; + +-- ① 集成动作类型字典扩展 +INSERT IGNORE INTO `sys_dict_item` (`id`,`dict_id`,`item_text`,`item_value`,`description`,`sort_order`,`status`,`create_by`,`create_time`) +VALUES +('1995000000000000394','1995000000000000360','审批环节同步','REGISTRY_STAGE_SYNC','按审批注册中心更新源单状态/操作人/痕迹',6,1,'admin',NOW()), +('1995000000000000395','1995000000000000360','审批环节回退','REGISTRY_STAGE_REVERT','驳回时回退源单状态并清空痕迹',7,1,'admin',NOW()); + +-- ② 停用旧版 SQL 手写演示方案(保留数据,改为已停用) +UPDATE `mes_xsl_integration_plan` +SET `status` = '2', `update_by` = 'admin', `update_time` = NOW() +WHERE `plan_code` IN ('mixer_ps_on_approve','mixer_ps_on_reject','mixer_ps_node_approve') + AND `del_flag` = 0; + +-- ③ 密炼PS — 校对环节通过 +INSERT IGNORE INTO `mes_xsl_integration_plan` + (`id`,`plan_code`,`plan_name`,`source_table`,`registry_id`,`trigger_phase`,`trigger_stage`,`exec_mode`,`status`,`remark`,`del_flag`,`tenant_id`,`create_by`,`create_time`) +SELECT + 'mxpsregproofread00001','mixer_ps_reg_proofread','密炼PS-校对通过(注册中心同步)', + 'mes_xsl_mixer_ps_compile', r.id, 'onNodeApprove', 'proofread', 'async', '0', + '无需Java回调:更新status=proofread并写校对人/时间+审批痕迹', 0, 0, 'admin', NOW() +FROM `mes_xsl_biz_doc_registry` r +WHERE r.table_name='mes_xsl_mixer_ps_compile' AND r.del_flag=0 AND r.enabled=1 +LIMIT 1; + +INSERT IGNORE INTO `mes_xsl_integration_action` + (`id`,`plan_id`,`action_name`,`action_type`,`action_config`,`exec_order`,`on_fail`,`enabled`,`del_flag`,`create_by`,`create_time`) +VALUES +('mxpsregproofreadact01','mxpsregproofread00001','校对环节同步','REGISTRY_STAGE_SYNC', + '{"visualType":"REGISTRY_STAGE_SYNC","stage":"proofread","expectedFrom":"compile"}', + 1,'stop',1,0,'admin',NOW()); + +-- ④ 密炼PS — 审核环节通过 +INSERT IGNORE INTO `mes_xsl_integration_plan` + (`id`,`plan_code`,`plan_name`,`source_table`,`registry_id`,`trigger_phase`,`trigger_stage`,`exec_mode`,`status`,`remark`,`del_flag`,`tenant_id`,`create_by`,`create_time`) +SELECT + 'mxpsregaudit00000002','mixer_ps_reg_audit','密炼PS-审核通过(注册中心同步)', + 'mes_xsl_mixer_ps_compile', r.id, 'onNodeApprove', 'audit', 'async', '0', + '无需Java回调:更新status=audit并写审核人/时间+审批痕迹', 0, 0, 'admin', NOW() +FROM `mes_xsl_biz_doc_registry` r +WHERE r.table_name='mes_xsl_mixer_ps_compile' AND r.del_flag=0 AND r.enabled=1 +LIMIT 1; + +INSERT IGNORE INTO `mes_xsl_integration_action` + (`id`,`plan_id`,`action_name`,`action_type`,`action_config`,`exec_order`,`on_fail`,`enabled`,`del_flag`,`create_by`,`create_time`) +VALUES +('mxpsregauditact00001','mxpsregaudit00000002','审核环节同步','REGISTRY_STAGE_SYNC', + '{"visualType":"REGISTRY_STAGE_SYNC","stage":"audit","expectedFrom":"proofread"}', + 1,'stop',1,0,'admin',NOW()); + +-- ⑤ 密炼PS — 批准(全流程通过) +INSERT IGNORE INTO `mes_xsl_integration_plan` + (`id`,`plan_code`,`plan_name`,`source_table`,`registry_id`,`trigger_phase`,`trigger_stage`,`exec_mode`,`status`,`remark`,`del_flag`,`tenant_id`,`create_by`,`create_time`) +SELECT + 'mxpsregapprove000003','mixer_ps_reg_approve','密炼PS-批准通过(注册中心同步)', + 'mes_xsl_mixer_ps_compile', r.id, 'onApprove', 'approve', 'async', '0', + '无需Java回调:更新status=approve并写批准人/时间+审批痕迹', 0, 0, 'admin', NOW() +FROM `mes_xsl_biz_doc_registry` r +WHERE r.table_name='mes_xsl_mixer_ps_compile' AND r.del_flag=0 AND r.enabled=1 +LIMIT 1; + +INSERT IGNORE INTO `mes_xsl_integration_action` + (`id`,`plan_id`,`action_name`,`action_type`,`action_config`,`exec_order`,`on_fail`,`enabled`,`del_flag`,`create_by`,`create_time`) +VALUES +('mxpsregapproveact01','mxpsregapprove000003','批准环节同步','REGISTRY_STAGE_SYNC', + '{"visualType":"REGISTRY_STAGE_SYNC","stage":"approve","expectedFrom":"audit"}', + 1,'stop',1,0,'admin',NOW()); + +-- ⑥ 密炼PS — 驳回回退编制 +INSERT IGNORE INTO `mes_xsl_integration_plan` + (`id`,`plan_code`,`plan_name`,`source_table`,`registry_id`,`trigger_phase`,`trigger_stage`,`exec_mode`,`status`,`remark`,`del_flag`,`tenant_id`,`create_by`,`create_time`) +SELECT + 'mxpsregreject000004','mixer_ps_reg_reject','密炼PS-驳回回退(注册中心同步)', + 'mes_xsl_mixer_ps_compile', r.id, 'onReject', NULL, 'async', '0', + '无需Java回调:回退status=compile并清空环节痕迹', 0, 0, 'admin', NOW() +FROM `mes_xsl_biz_doc_registry` r +WHERE r.table_name='mes_xsl_mixer_ps_compile' AND r.del_flag=0 AND r.enabled=1 +LIMIT 1; + +INSERT IGNORE INTO `mes_xsl_integration_action` + (`id`,`plan_id`,`action_name`,`action_type`,`action_config`,`exec_order`,`on_fail`,`enabled`,`del_flag`,`create_by`,`create_time`) +VALUES +('mxpsregrejectact001','mxpsregreject000004','驳回回退编制','REGISTRY_STAGE_REVERT', + '{"visualType":"REGISTRY_STAGE_REVERT","targetStage":"compile"}', + 1,'stop',1,0,'admin',NOW()); diff --git a/jeecgboot-vue3/src/components/ApprovalDesign/index.vue b/jeecgboot-vue3/src/components/ApprovalDesign/index.vue index 10196a49..ed3da291 100644 --- a/jeecgboot-vue3/src/components/ApprovalDesign/index.vue +++ b/jeecgboot-vue3/src/components/ApprovalDesign/index.vue @@ -2,8 +2,8 @@ 全局「审批流程设计」悬浮按钮 拥有 approval:flow:design 权限的用户,在任意功能页点击即可: 1)后端按当前页路由反查绑定的业务表; - 2)解析该表字段,识别「校对/审核/审批/分发/抄送」等阶段字段(不存在不报错); - 3)进入可视化设计器,可点选识别到的阶段字段按顺序生成审批流程并保存发布。 + 2)从审批注册中心读取该单据已启用的审批环节; + 3)进入可视化设计器,可点选启用环节按顺序生成审批节点,也支持手动添加节点。 @author GHT @date 2026-05-29 for:【QH-MES审批流设计】全局审批流程设计悬浮按钮 --> diff --git a/jeecgboot-vue3/src/views/approval/flow/ApprovalFlowList.vue b/jeecgboot-vue3/src/views/approval/flow/ApprovalFlowList.vue index 12997027..6bf9753f 100644 --- a/jeecgboot-vue3/src/views/approval/flow/ApprovalFlowList.vue +++ b/jeecgboot-vue3/src/views/approval/flow/ApprovalFlowList.vue @@ -36,7 +36,13 @@ import { useListPage } from '/@/hooks/system/useListPage'; import { useMessage } from '/@/hooks/web/useMessage'; import { columns, searchFormSchema } from './approvalFlow.data'; - import { getApprovalFlowList, deleteApprovalFlow, batchDeleteApprovalFlow, updateApprovalFlowStatus } from './approvalFlow.api'; + import { + getApprovalFlowList, + deleteApprovalFlow, + batchDeleteApprovalFlow, + updateApprovalFlowStatus, + getApprovalRegistryStages, + } from './approvalFlow.api'; import ApprovalFlowModal from './ApprovalFlowModal.vue'; import FlowDesign from './components/FlowDesign.vue'; @@ -73,9 +79,17 @@ openModal(true, { isUpdate: true, record }); } - // 打开可视化设计器 - function handleDesign(record, readonly = false) { - openDesign(true, { record, readonly }); + // 打开可视化设计器(加载审批注册中心启用环节) + async function handleDesign(record, readonly = false) { + let paletteStages: any[] = []; + if (record?.bizTable) { + try { + paletteStages = (await getApprovalRegistryStages(record.bizTable)) || []; + } catch { + paletteStages = []; + } + } + openDesign(true, { record, readonly, paletteStages }); } function handleDelete(record) { diff --git a/jeecgboot-vue3/src/views/approval/flow/approvalFlow.api.ts b/jeecgboot-vue3/src/views/approval/flow/approvalFlow.api.ts index 76050595..81f13879 100644 --- a/jeecgboot-vue3/src/views/approval/flow/approvalFlow.api.ts +++ b/jeecgboot-vue3/src/views/approval/flow/approvalFlow.api.ts @@ -16,6 +16,7 @@ enum Api { deleteBatch = '/xslmes/approvalFlow/deleteBatch', // update-begin---author:GHT ---date:2026-05-29 for:【QH-MES审批流设计】当前页设计上下文----- designContext = '/xslmes/approvalFlow/designContext', + registryStages = '/xslmes/approvalFlow/registryStages', // update-end---author:GHT ---date:2026-05-29 for:【QH-MES审批流设计】当前页设计上下文----- // update-begin---author:GHT ---date:2026-05-29 for:【QH-MES审批流设计】业务表可选回调动作----- bizActions = '/xslmes/approvalFlow/bizActions', @@ -72,9 +73,12 @@ export const batchDeleteApprovalFlow = (params, handleSuccess) => { /** * 获取当前功能页的审批流设计上下文: * 返回 { routePath, bizTable, bizTableName, stages[], flow }, - * stages 为识别到的阶段字段(校对/审核/审批/分发/抄送),flow 为可直接进入设计器的流程记录。 + * stages 为审批注册中心已启用环节,flow 为可直接进入设计器的流程记录。 */ export const getApprovalDesignContext = (routePath: string) => defHttp.get({ url: Api.designContext, params: { routePath } }); + +/** 按业务表查询审批注册中心启用环节(列表页「设计」入口用) */ +export const getApprovalRegistryStages = (bizTable: string) => defHttp.get({ url: Api.registryStages, params: { bizTable } }); // update-end---author:GHT ---date:2026-05-29 for:【QH-MES审批流设计】当前页设计上下文(解析阶段字段+取/建草稿流程)----- // update-begin---author:GHT ---date:2026-05-29 for:【QH-MES审批流设计】业务表可选回调动作(后端@ApprovalBizAction注解扫描)----- @@ -84,3 +88,12 @@ export const getApprovalDesignContext = (routePath: string) => defHttp.get({ url */ export const getApprovalBizActions = (table: string) => defHttp.get({ url: Api.bizActions, params: { table } }); // update-end---author:GHT ---date:2026-05-29 for:【QH-MES审批流设计】业务表可选回调动作----- + +// update-begin---author:GHT ---date:2026-06-05 for:【XSLMES-20260605-K8R2】流程节点绑定已发布集成方案----- +/** 查询某业务表、某触发时机下已发布的集成方案,供节点配置下拉 */ +export const listPublishedIntegrationPlans = (params: { sourceTable: string; triggerPhase: string }) => + defHttp.get({ + url: '/xslmes/mesXslIntegrationPlan/list', + params: { ...params, status: '1', pageNo: 1, pageSize: 200 }, + }); +// update-end---author:GHT ---date:2026-06-05 for:【XSLMES-20260605-K8R2】流程节点绑定已发布集成方案----- diff --git a/jeecgboot-vue3/src/views/approval/flow/components/FlowDesign.vue b/jeecgboot-vue3/src/views/approval/flow/components/FlowDesign.vue index 057521a3..4cd549cc 100644 --- a/jeecgboot-vue3/src/views/approval/flow/components/FlowDesign.vue +++ b/jeecgboot-vue3/src/views/approval/flow/components/FlowDesign.vue @@ -11,12 +11,17 @@ 审批流:{{ record.flowName }} 点击节点可配置,点击节点间的「+」可插入审批人 / 抄送人 / 条件分支 - +

-
-
当前页识别到的审批阶段
-
点击下方阶段,按顺序追加到流程末尾;处理人将取自单据对应字段。
-
+
+
审批注册中心启用环节
+
+ 点击下方环节按顺序追加到流程末尾,处理人取自注册中心配置的人员字段。 +
+
+ 该单据未在「审批注册中心」配置启用环节。您仍可通过节点间「+」手动添加审批人节点(仅发起审批时生效)。 +
+
{{ s.stageName }}
{{ s.fieldComment || s.field }}
@@ -34,7 +39,7 @@
- +
@@ -71,9 +76,10 @@ // 当前审批流绑定的业务表,供节点配置按表查可选回调动作 const bizTableRef = ref(''); provide('approvalBizTable', bizTableRef); - // update-begin---author:GHT ---date:2026-05-29 for:【QH-MES审批流设计】当前页识别到的候选阶段字段----- --> + provide('approvalFlowRoot', root); + // update-begin---author:GHT ---date:2026-05-29 for:【QH-MES审批流设计】审批注册中心候选环节----- --> const paletteStages = ref([]); - // update-end---author:GHT ---date:2026-05-29 for:【QH-MES审批流设计】当前页识别到的候选阶段字段----- --> + // update-end---author:GHT ---date:2026-05-29 for:【QH-MES审批流设计】审批注册中心候选环节----- --> const modalTitle = computed(() => (readonly.value ? '查看审批流' : '设计审批流')); @@ -108,9 +114,9 @@ readonly.value = !!data?.readonly; flowCtx.readonly = readonly.value; bizTableRef.value = data?.record?.bizTable || ''; - // update-begin---author:GHT ---date:2026-05-29 for:【QH-MES审批流设计】接收当前页解析出的候选阶段字段----- --> + // update-begin---author:GHT ---date:2026-05-29 for:【QH-MES审批流设计】接收审批注册中心候选环节----- --> paletteStages.value = Array.isArray(data?.paletteStages) ? data.paletteStages : []; - // update-end---author:GHT ---date:2026-05-29 for:【QH-MES审批流设计】接收当前页解析出的候选阶段字段----- --> + // update-end---author:GHT ---date:2026-05-29 for:【QH-MES审批流设计】接收审批注册中心候选环节----- --> const id = data?.record?.id || ''; Object.assign(record, { id, diff --git a/jeecgboot-vue3/src/views/approval/flow/components/NodeConfigDrawer.vue b/jeecgboot-vue3/src/views/approval/flow/components/NodeConfigDrawer.vue index b66a1d89..e3238df8 100644 --- a/jeecgboot-vue3/src/views/approval/flow/components/NodeConfigDrawer.vue +++ b/jeecgboot-vue3/src/views/approval/flow/components/NodeConfigDrawer.vue @@ -87,48 +87,76 @@ - - 回调接口(审批联动业务) + + 审批环节绑定 + + + 不绑定(纯过路审批,不改变单据状态) + 校对 + 审核 + 批准 + +
+ + 未设置:钉钉回调时按节点名称或单据状态自动匹配(旧版兼容模式) + + + 纯过路审批:此节点通过后不触发任何集成动作,不改变单据状态 + + + 关键节点:此节点通过后触发「{{ stageKeyLabel(form.props.stageKey) }}」环节的集成方案 + +
+
+ + + + 集成方案(审批联动业务) -
-
{{ phase.label }}
-
- - - - -
- - - 手动添加 - + +
+
{{ isLastApproverNode ? '本节点通过 / 流程最终通过时执行' : '本节点通过时执行' }}
+ +
+
+
流程最终通过时执行
+
- - + @@ -200,55 +228,132 @@ diff --git a/jeecgboot-vue3/src/views/xslmes/approval/integration/MesXslBizDocRegistry.api.ts b/jeecgboot-vue3/src/views/xslmes/approval/integration/MesXslBizDocRegistry.api.ts new file mode 100644 index 00000000..10491844 --- /dev/null +++ b/jeecgboot-vue3/src/views/xslmes/approval/integration/MesXslBizDocRegistry.api.ts @@ -0,0 +1,32 @@ +import { defHttp } from '/@/utils/http/axios'; +import { useMessage } from '/@/hooks/web/useMessage'; + +const { createConfirm } = useMessage(); + +enum Api { + list = '/xslmes/mesXslBizDocRegistry/list', + save = '/xslmes/mesXslBizDocRegistry/add', + edit = '/xslmes/mesXslBizDocRegistry/edit', + deleteOne = '/xslmes/mesXslBizDocRegistry/delete', + deleteBatch = '/xslmes/mesXslBizDocRegistry/deleteBatch', +} + +export const list = (params) => defHttp.get({ url: Api.list, params }); + +export const saveOrUpdate = (params, isUpdate) => + isUpdate ? defHttp.put({ url: Api.edit, params }) : defHttp.post({ url: Api.save, params }); + +export const deleteOne = (params, handleSuccess) => + defHttp.delete({ url: Api.deleteOne, params }, { joinParamsToUrl: true }).then(() => handleSuccess()); + +export const batchDelete = (params, handleSuccess) => { + createConfirm({ + iconType: 'warning', + title: '确认删除', + content: '是否删除选中数据', + okText: '确认', + cancelText: '取消', + onOk: () => + defHttp.delete({ url: Api.deleteBatch, params }, { joinParamsToUrl: true }).then(() => handleSuccess()), + }); +}; diff --git a/jeecgboot-vue3/src/views/xslmes/approval/integration/MesXslBizDocRegistry.data.ts b/jeecgboot-vue3/src/views/xslmes/approval/integration/MesXslBizDocRegistry.data.ts new file mode 100644 index 00000000..5a46c370 --- /dev/null +++ b/jeecgboot-vue3/src/views/xslmes/approval/integration/MesXslBizDocRegistry.data.ts @@ -0,0 +1,134 @@ +import { BasicColumn, FormSchema } from '/@/components/Table'; + +const STAGE_DICT = 'mes_xsl_approval_stage'; + +function hasStage(values: Recordable, stage: string) { + const raw = values?.enabledStages; + if (!raw) return false; + if (Array.isArray(raw)) return raw.includes(stage); + return String(raw).split(',').includes(stage); +} + +export const columns: BasicColumn[] = [ + { title: '业务编码', dataIndex: 'docCode', width: 140, align: 'left' }, + { title: '物理表名', dataIndex: 'tableName', width: 200, align: 'left' }, + { title: '中文名称', dataIndex: 'displayName', width: 140 }, + { title: '启用环节', dataIndex: 'enabledStages_dictText', width: 180, ellipsis: true }, + { title: '启用', dataIndex: 'enabled_dictText', width: 70 }, + { title: '备注', dataIndex: 'remark', ellipsis: true }, + { title: '创建时间', dataIndex: 'createTime', width: 160 }, +]; + +export const searchFormSchema: FormSchema[] = [ + { label: '业务编码', field: 'docCode', component: 'JInput', colProps: { span: 6 } }, + { label: '表名/中文名', field: 'displayName', component: 'JInput', colProps: { span: 6 } }, + { + label: '启用', + field: 'enabled', + component: 'JDictSelectTag', + componentProps: { dictCode: 'yn' }, + colProps: { span: 6 }, + }, +]; + +export const formSchema: FormSchema[] = [ + { label: '', field: 'id', component: 'Input', show: false }, + { + label: '业务编码', + field: 'docCode', + component: 'Input', + componentProps: { placeholder: '唯一标识,如 mixer_ps_compile' }, + dynamicRules: () => [{ required: true, message: '请输入业务编码!' }], + }, + { + label: '物理表名', + field: 'tableName', + component: 'Input', + componentProps: { placeholder: '数据库表名,如 mes_xsl_mixer_ps_compile' }, + dynamicRules: () => [{ required: true, message: '请输入物理表名!' }], + }, + { + label: '中文名称', + field: 'displayName', + component: 'Input', + componentProps: { placeholder: '如 密炼PS编制' }, + }, + { + label: '启用', + field: 'enabled', + component: 'Switch', + defaultValue: 1, + componentProps: { + checkedValue: 1, + unCheckedValue: 0, + checkedChildren: '是', + unCheckedChildren: '否', + }, + }, + { + label: '启用环节', + field: 'enabledStages', + component: 'JDictSelectTag', + componentProps: { + dictCode: STAGE_DICT, + mode: 'multiple', + placeholder: '多选:校对 / 审核 / 批准', + }, + helpMessage: '勾选后该业务表才允许执行对应环节,并写入审批痕迹明细', + }, + { + label: '状态字段', + field: 'statusField', + component: 'Input', + defaultValue: 'status', + componentProps: { placeholder: '默认 status' }, + }, + { + label: '校对人字段', + field: 'proofreadByField', + component: 'Input', + defaultValue: 'proofread_by', + ifShow: ({ values }) => hasStage(values, 'proofread'), + }, + { + label: '校对时间字段', + field: 'proofreadTimeField', + component: 'Input', + defaultValue: 'proofread_time', + ifShow: ({ values }) => hasStage(values, 'proofread'), + }, + { + label: '审核人字段', + field: 'auditByField', + component: 'Input', + defaultValue: 'audit_by', + ifShow: ({ values }) => hasStage(values, 'audit'), + }, + { + label: '审核时间字段', + field: 'auditTimeField', + component: 'Input', + defaultValue: 'audit_time', + ifShow: ({ values }) => hasStage(values, 'audit'), + }, + { + label: '批准人字段', + field: 'approveByField', + component: 'Input', + defaultValue: 'approve_by', + ifShow: ({ values }) => hasStage(values, 'approve'), + }, + { + label: '批准时间字段', + field: 'approveTimeField', + component: 'Input', + defaultValue: 'approve_time', + ifShow: ({ values }) => hasStage(values, 'approve'), + }, + { + label: '备注', + field: 'remark', + component: 'InputTextArea', + componentProps: { rows: 3 }, + }, +]; diff --git a/jeecgboot-vue3/src/views/xslmes/approval/integration/MesXslBizDocRegistryList.vue b/jeecgboot-vue3/src/views/xslmes/approval/integration/MesXslBizDocRegistryList.vue new file mode 100644 index 00000000..6683deec --- /dev/null +++ b/jeecgboot-vue3/src/views/xslmes/approval/integration/MesXslBizDocRegistryList.vue @@ -0,0 +1,104 @@ + + + diff --git a/jeecgboot-vue3/src/views/xslmes/approval/integration/MesXslIntegrationLog.api.ts b/jeecgboot-vue3/src/views/xslmes/approval/integration/MesXslIntegrationLog.api.ts new file mode 100644 index 00000000..2b6182c8 --- /dev/null +++ b/jeecgboot-vue3/src/views/xslmes/approval/integration/MesXslIntegrationLog.api.ts @@ -0,0 +1,9 @@ +import { defHttp } from '/@/utils/http/axios'; + +enum Api { + list = '/xslmes/mesXslIntegrationLog/list', + retry = '/xslmes/mesXslIntegrationLog/retry', +} + +export const list = (params) => defHttp.get({ url: Api.list, params }); +export const retry = (id) => defHttp.post({ url: Api.retry, params: { id } }, { joinParamsToUrl: true }); diff --git a/jeecgboot-vue3/src/views/xslmes/approval/integration/MesXslIntegrationLog.data.ts b/jeecgboot-vue3/src/views/xslmes/approval/integration/MesXslIntegrationLog.data.ts new file mode 100644 index 00000000..2ffd59c4 --- /dev/null +++ b/jeecgboot-vue3/src/views/xslmes/approval/integration/MesXslIntegrationLog.data.ts @@ -0,0 +1,30 @@ +import { BasicColumn, FormSchema } from '/@/components/Table'; + +export const columns: BasicColumn[] = [ + { title: '源单表', dataIndex: 'sourceBizTable', width: 180, align: 'left' }, + { title: '源单ID', dataIndex: 'sourceBizId', width: 120 }, + { title: '状态', dataIndex: 'status_dictText', width: 90 }, + { title: '耗时(ms)', dataIndex: 'execTimeMs', width: 90 }, + { title: '重试次数', dataIndex: 'retryCount', width: 80 }, + { title: '错误信息', dataIndex: 'errorMessage', ellipsis: true }, + { title: '幂等键', dataIndex: 'idempotentKey', width: 200, ellipsis: true }, + { title: '创建时间', dataIndex: 'createTime', width: 160 }, +]; + +export const searchFormSchema: FormSchema[] = [ + { label: '源单表', field: 'sourceBizTable', component: 'JInput', colProps: { span: 6 } }, + { label: '源单ID', field: 'sourceBizId', component: 'JInput', colProps: { span: 6 } }, + { + label: '执行状态', + field: 'status', + component: 'JDictSelectTag', + componentProps: { dictCode: 'mes_xsl_integration_log_status' }, + colProps: { span: 6 }, + }, + { + label: '创建时间', + field: 'createTime', + component: 'RangePicker', + colProps: { span: 8 }, + }, +]; diff --git a/jeecgboot-vue3/src/views/xslmes/approval/integration/MesXslIntegrationLogList.vue b/jeecgboot-vue3/src/views/xslmes/approval/integration/MesXslIntegrationLogList.vue new file mode 100644 index 00000000..dce16a46 --- /dev/null +++ b/jeecgboot-vue3/src/views/xslmes/approval/integration/MesXslIntegrationLogList.vue @@ -0,0 +1,87 @@ + + + + + diff --git a/jeecgboot-vue3/src/views/xslmes/approval/integration/MesXslIntegrationPlan.api.ts b/jeecgboot-vue3/src/views/xslmes/approval/integration/MesXslIntegrationPlan.api.ts new file mode 100644 index 00000000..942fb169 --- /dev/null +++ b/jeecgboot-vue3/src/views/xslmes/approval/integration/MesXslIntegrationPlan.api.ts @@ -0,0 +1,57 @@ +import { defHttp } from '/@/utils/http/axios'; +import { useMessage } from '/@/hooks/web/useMessage'; + +const { createConfirm } = useMessage(); + +enum Api { + list = '/xslmes/mesXslIntegrationPlan/list', + save = '/xslmes/mesXslIntegrationPlan/add', + edit = '/xslmes/mesXslIntegrationPlan/edit', + deleteOne = '/xslmes/mesXslIntegrationPlan/delete', + publish = '/xslmes/mesXslIntegrationPlan/publish', + disable = '/xslmes/mesXslIntegrationPlan/disable', + tableColumns = '/xslmes/mesXslIntegrationPlan/tableColumns', + actionList = '/xslmes/mesXslIntegrationPlan/action/listByPlanId', + actionAdd = '/xslmes/mesXslIntegrationPlan/action/add', + actionEdit = '/xslmes/mesXslIntegrationPlan/action/edit', + actionDelete = '/xslmes/mesXslIntegrationPlan/action/delete', + bizDocList = '/xslmes/mesXslBizDocRegistry/list', + registryByTable = '/xslmes/mesXslIntegrationPlan/registryByTable', + previewDefaultFromFlow = '/xslmes/mesXslIntegrationPlan/previewDefaultFromFlow', + generateDefaultFromFlow = '/xslmes/mesXslIntegrationPlan/generateDefaultFromFlow', +} + +export const list = (params) => defHttp.get({ url: Api.list, params }); + +export const saveOrUpdate = (params, isUpdate) => + isUpdate ? defHttp.put({ url: Api.edit, params }) : defHttp.post({ url: Api.save, params }); + +export const deleteOne = (params, handleSuccess) => + defHttp.delete({ url: Api.deleteOne, params }, { joinParamsToUrl: true }).then(() => handleSuccess()); + +export const publishPlan = (id) => defHttp.post({ url: Api.publish, params: { id } }, { joinParamsToUrl: true }); +export const disablePlan = (id) => defHttp.post({ url: Api.disable, params: { id } }, { joinParamsToUrl: true }); + +// 可视化向导专用 +export const savePlan = (params) => defHttp.post({ url: Api.save, params }); +export const getTableColumns = (tableName: string) => defHttp.get({ url: Api.tableColumns, params: { tableName } }); +export const listBizDocRegistry = () => defHttp.get({ url: Api.bizDocList, params: { pageNo: 1, pageSize: 200 } }); +export const getRegistryByTable = (tableName: string) => defHttp.get({ url: Api.registryByTable, params: { tableName } }); +export const getDictItems = (dictCode: string) => defHttp.get({ url: `/sys/dict/getDictItems/${dictCode}` }); + +export const previewDefaultFromFlow = (params: { sourceTable: string; flowId?: string }) => + defHttp.get({ url: Api.previewDefaultFromFlow, params }); + +export const generateDefaultFromFlow = (params: { + sourceTable: string; + flowId?: string; + overwriteDraft?: boolean; + nodeBindings?: Array<{ nodeId: string; stage?: string | null }>; +}) => defHttp.post({ url: Api.generateDefaultFromFlow, params }); + +// 动作管理 +export const listActions = (planId) => defHttp.get({ url: Api.actionList, params: { planId } }); +export const saveAction = (params) => defHttp.post({ url: Api.actionAdd, params }); +export const editAction = (params) => defHttp.put({ url: Api.actionEdit, params }); +export const deleteAction = (params, handleSuccess) => + defHttp.delete({ url: Api.actionDelete, params }, { joinParamsToUrl: true }).then(() => handleSuccess()); diff --git a/jeecgboot-vue3/src/views/xslmes/approval/integration/MesXslIntegrationPlan.data.ts b/jeecgboot-vue3/src/views/xslmes/approval/integration/MesXslIntegrationPlan.data.ts new file mode 100644 index 00000000..66478ec2 --- /dev/null +++ b/jeecgboot-vue3/src/views/xslmes/approval/integration/MesXslIntegrationPlan.data.ts @@ -0,0 +1,156 @@ +import { BasicColumn, FormSchema } from '/@/components/Table'; + +export const columns: BasicColumn[] = [ + { title: '方案编码', dataIndex: 'planCode', width: 150, align: 'left' }, + { title: '方案名称', dataIndex: 'planName', width: 180, align: 'left' }, + { title: '源单表', dataIndex: 'sourceTable', width: 200, align: 'left' }, + { title: '触发时机', dataIndex: 'triggerPhase_dictText', width: 110 }, + { title: '绑定环节', dataIndex: 'triggerStage_dictText', width: 90 }, + { title: '执行模式', dataIndex: 'execMode_dictText', width: 100 }, + { title: '状态', dataIndex: 'status_dictText', width: 90 }, + { title: '创建时间', dataIndex: 'createTime', width: 160 }, +]; + +export const searchFormSchema: FormSchema[] = [ + { label: '方案编码', field: 'planCode', component: 'JInput', colProps: { span: 6 } }, + { label: '方案名称', field: 'planName', component: 'JInput', colProps: { span: 6 } }, + { + label: '状态', + field: 'status', + component: 'JDictSelectTag', + componentProps: { dictCode: 'mes_xsl_integration_plan_status' }, + colProps: { span: 6 }, + }, + { + label: '触发时机', + field: 'triggerPhase', + component: 'JDictSelectTag', + componentProps: { dictCode: 'mes_xsl_integration_trigger_phase' }, + colProps: { span: 6 }, + }, +]; + +export const formSchema: FormSchema[] = [ + { label: '', field: 'id', component: 'Input', show: false }, + { + label: '方案编码', + field: 'planCode', + component: 'Input', + componentProps: { placeholder: '唯一编码,如 formula_approve_sync' }, + dynamicRules: () => [{ required: true, message: '请输入方案编码!' }], + }, + { + label: '方案名称', + field: 'planName', + component: 'Input', + componentProps: { placeholder: '如 配合示方审批通过同步ERP' }, + dynamicRules: () => [{ required: true, message: '请输入方案名称!' }], + }, + { + label: '源单表名', + field: 'sourceTable', + component: 'Input', + componentProps: { placeholder: '触发的业务表,如 mes_xsl_formula_spec' }, + dynamicRules: () => [{ required: true, message: '请输入源单表名!' }], + }, + { + label: '触发时机', + field: 'triggerPhase', + component: 'JDictSelectTag', + componentProps: { dictCode: 'mes_xsl_integration_trigger_phase', type: 'select' }, + dynamicRules: () => [{ required: true, message: '请选择触发时机!' }], + }, + { + label: '绑定环节', + field: 'triggerStage', + component: 'JDictSelectTag', + componentProps: { dictCode: 'mes_xsl_approval_stage', type: 'select', placeholder: '节点通过时必选' }, + helpMessage: '须为审批注册中心已启用的环节;节点通过时必选,全流程通过默认批准', + }, + { + label: '执行模式', + field: 'execMode', + component: 'JDictSelectTag', + componentProps: { dictCode: 'mes_xsl_integration_exec_mode', type: 'select' }, + defaultValue: 'async', + }, + { + label: '匹配条件', + field: 'matchCondition', + component: 'InputTextArea', + componentProps: { rows: 2, placeholder: '可选,留空表示无条件匹配。如:status = \'approved\'' }, + }, + { + label: '备注', + field: 'remark', + component: 'InputTextArea', + componentProps: { rows: 2 }, + }, +]; + +// 动作表格列 +export const actionColumns: BasicColumn[] = [ + { title: '动作名称', dataIndex: 'actionName', width: 140, align: 'left' }, + { title: '动作类型', dataIndex: 'actionType_dictText', width: 110 }, + { title: '失败策略', dataIndex: 'onFail_dictText', width: 90 }, + { title: '执行顺序', dataIndex: 'execOrder', width: 80 }, + { title: '启用', dataIndex: 'enabled_dictText', width: 70 }, + { title: 'SQL 模板', dataIndex: 'sqlTemplate', ellipsis: true }, +]; + +// 动作表单 +export const actionFormSchema: FormSchema[] = [ + { label: '', field: 'id', component: 'Input', show: false }, + { label: '', field: 'planId', component: 'Input', show: false }, + { + label: '动作名称', + field: 'actionName', + component: 'Input', + componentProps: { placeholder: '如 更新ERP状态' }, + dynamicRules: () => [{ required: true, message: '请输入动作名称!' }], + }, + { + label: '动作类型', + field: 'actionType', + component: 'JDictSelectTag', + componentProps: { dictCode: 'mes_xsl_integration_action_type', type: 'select' }, + defaultValue: 'SQL_UPDATE', + dynamicRules: () => [{ required: true, message: '请选择动作类型!' }], + }, + { + label: 'SQL 模板', + field: 'sqlTemplate', + component: 'InputTextArea', + componentProps: { + rows: 5, + placeholder: 'UPDATE mes_xsl_xxx SET status=\'approved\' WHERE id=#{source.id}', + }, + ifShow: ({ values }) => values.actionType === 'SQL_UPDATE', + }, + { + label: '执行顺序', + field: 'execOrder', + component: 'InputNumber', + componentProps: { min: 0, max: 999 }, + defaultValue: 0, + }, + { + label: '失败策略', + field: 'onFail', + component: 'JDictSelectTag', + componentProps: { dictCode: 'mes_xsl_integration_on_fail', type: 'select' }, + defaultValue: 'stop', + }, + { + label: '幂等键', + field: 'idempotentKey', + component: 'Input', + componentProps: { placeholder: '留空默认使用 台账ID_动作ID' }, + }, + { + label: '启用', + field: 'enabled', + component: 'Switch', + defaultValue: true, + }, +]; diff --git a/jeecgboot-vue3/src/views/xslmes/approval/integration/MesXslIntegrationPlanList.vue b/jeecgboot-vue3/src/views/xslmes/approval/integration/MesXslIntegrationPlanList.vue new file mode 100644 index 00000000..bbbed574 --- /dev/null +++ b/jeecgboot-vue3/src/views/xslmes/approval/integration/MesXslIntegrationPlanList.vue @@ -0,0 +1,150 @@ + + + diff --git a/jeecgboot-vue3/src/views/xslmes/approval/integration/MesXslIntegrationPlanWizard.vue b/jeecgboot-vue3/src/views/xslmes/approval/integration/MesXslIntegrationPlanWizard.vue new file mode 100644 index 00000000..ccd87732 --- /dev/null +++ b/jeecgboot-vue3/src/views/xslmes/approval/integration/MesXslIntegrationPlanWizard.vue @@ -0,0 +1,466 @@ + + + diff --git a/jeecgboot-vue3/src/views/xslmes/approval/integration/components/GenerateDefaultPlanModal.vue b/jeecgboot-vue3/src/views/xslmes/approval/integration/components/GenerateDefaultPlanModal.vue new file mode 100644 index 00000000..39beea7b --- /dev/null +++ b/jeecgboot-vue3/src/views/xslmes/approval/integration/components/GenerateDefaultPlanModal.vue @@ -0,0 +1,332 @@ + + + diff --git a/jeecgboot-vue3/src/views/xslmes/approval/integration/components/MesXslApprovalTraceDrawer.vue b/jeecgboot-vue3/src/views/xslmes/approval/integration/components/MesXslApprovalTraceDrawer.vue new file mode 100644 index 00000000..27325705 --- /dev/null +++ b/jeecgboot-vue3/src/views/xslmes/approval/integration/components/MesXslApprovalTraceDrawer.vue @@ -0,0 +1,49 @@ + + + diff --git a/jeecgboot-vue3/src/views/xslmes/approval/integration/components/MesXslBizDocRegistryModal.vue b/jeecgboot-vue3/src/views/xslmes/approval/integration/components/MesXslBizDocRegistryModal.vue new file mode 100644 index 00000000..d833bc3f --- /dev/null +++ b/jeecgboot-vue3/src/views/xslmes/approval/integration/components/MesXslBizDocRegistryModal.vue @@ -0,0 +1,69 @@ + + + diff --git a/jeecgboot-vue3/src/views/xslmes/approval/integration/components/MesXslIntegrationActionDrawer.vue b/jeecgboot-vue3/src/views/xslmes/approval/integration/components/MesXslIntegrationActionDrawer.vue new file mode 100644 index 00000000..0d96dc8a --- /dev/null +++ b/jeecgboot-vue3/src/views/xslmes/approval/integration/components/MesXslIntegrationActionDrawer.vue @@ -0,0 +1,195 @@ + + + diff --git a/jeecgboot-vue3/src/views/xslmes/approval/integration/components/MesXslIntegrationPlanModal.vue b/jeecgboot-vue3/src/views/xslmes/approval/integration/components/MesXslIntegrationPlanModal.vue new file mode 100644 index 00000000..d6bc8dc8 --- /dev/null +++ b/jeecgboot-vue3/src/views/xslmes/approval/integration/components/MesXslIntegrationPlanModal.vue @@ -0,0 +1,46 @@ + + + diff --git a/jeecgboot-vue3/src/views/xslmes/approval/integration/components/VisualActionEditor.vue b/jeecgboot-vue3/src/views/xslmes/approval/integration/components/VisualActionEditor.vue new file mode 100644 index 00000000..69778439 --- /dev/null +++ b/jeecgboot-vue3/src/views/xslmes/approval/integration/components/VisualActionEditor.vue @@ -0,0 +1,753 @@ + + + diff --git a/jeecgboot-vue3/src/views/xslmes/mesXslMixerPsCompile/MesXslMixerPsCompile.api.ts b/jeecgboot-vue3/src/views/xslmes/mesXslMixerPsCompile/MesXslMixerPsCompile.api.ts index 42338dbf..6662366f 100644 --- a/jeecgboot-vue3/src/views/xslmes/mesXslMixerPsCompile/MesXslMixerPsCompile.api.ts +++ b/jeecgboot-vue3/src/views/xslmes/mesXslMixerPsCompile/MesXslMixerPsCompile.api.ts @@ -9,9 +9,11 @@ enum Api { importExcel = '/xslmes/mesXslMixerPsCompile/importExcel', exportXls = '/xslmes/mesXslMixerPsCompile/exportXls', queryById = '/xslmes/mesXslMixerPsCompile/queryById', - proofread = '/xslmes/mesXslMixerPsCompile/proofread', - audit = '/xslmes/mesXslMixerPsCompile/audit', - approve = '/xslmes/mesXslMixerPsCompile/approve', + // update-begin---author:GHT ---date:2026-06-05 for:【XSLMES-20260605-K8R2】校对/审核/批准接口停用,改由审批流+集成方案驱动 + // proofread = '/xslmes/mesXslMixerPsCompile/proofread', + // audit = '/xslmes/mesXslMixerPsCompile/audit', + // approve = '/xslmes/mesXslMixerPsCompile/approve', + // update-end---author:GHT ---date:2026-06-05 for:【XSLMES-20260605-K8R2】 } export const list = (params) => defHttp.get({ url: Api.list, params }); @@ -32,8 +34,8 @@ export const saveOrUpdate = (params, isUpdate) => { export const getExportUrl = Api.exportXls; export const getImportUrl = Api.importExcel; -export const proofread = (params: { ids: string }) => defHttp.post({ url: Api.proofread, params }, { joinParamsToUrl: true }); - -export const audit = (params: { ids: string }) => defHttp.post({ url: Api.audit, params }, { joinParamsToUrl: true }); - -export const approve = (params: { ids: string }) => defHttp.post({ url: Api.approve, params }, { joinParamsToUrl: true }); +// update-begin---author:GHT ---date:2026-06-05 for:【XSLMES-20260605-K8R2】校对/审核/批准接口停用,保留代码备后期恢复 +// export const proofread = (params: { ids: string }) => defHttp.post({ url: Api.proofread, params }, { joinParamsToUrl: true }); +// export const audit = (params: { ids: string }) => defHttp.post({ url: Api.audit, params }, { joinParamsToUrl: true }); +// export const approve = (params: { ids: string }) => defHttp.post({ url: Api.approve, params }, { joinParamsToUrl: true }); +// update-end---author:GHT ---date:2026-06-05 for:【XSLMES-20260605-K8R2】 diff --git a/jeecgboot-vue3/src/views/xslmes/mesXslMixerPsCompile/MesXslMixerPsCompileList.vue b/jeecgboot-vue3/src/views/xslmes/mesXslMixerPsCompile/MesXslMixerPsCompileList.vue index 12532099..fbca0c06 100644 --- a/jeecgboot-vue3/src/views/xslmes/mesXslMixerPsCompile/MesXslMixerPsCompileList.vue +++ b/jeecgboot-vue3/src/views/xslmes/mesXslMixerPsCompile/MesXslMixerPsCompileList.vue @@ -10,30 +10,8 @@ > 新增 - - 校对 - - - 审核 - - - 批准 - + + import { computed, reactive } from 'vue'; - import { Modal } from 'ant-design-vue'; import { BasicTable, TableAction } from '/@/components/Table'; import { useModal } from '/@/components/Modal'; import { useListPage } from '/@/hooks/system/useListPage'; @@ -100,9 +77,6 @@ batchDelete, getExportUrl, getImportUrl, - proofread, - audit, - approve, } from './MesXslMixerPsCompile.api'; const { createMessage } = useMessage(); @@ -171,38 +145,6 @@ openModal(true, { record, isUpdate: true, showFooter: true }); } - function handleStatusAction(action: 'proofread' | 'audit' | 'approve', label: string) { - if (selectedRowKeys.value.length === 0) { - createMessage.warning('请先选择要' + label + '的记录'); - return; - } - Modal.confirm({ - title: '确认' + label, - content: `确定对选中的 ${selectedRowKeys.value.length} 条记录执行${label}吗?`, - okText: '确认', - cancelText: '取消', - onOk: async () => { - const ids = selectedRowKeys.value.join(','); - const fn = action === 'proofread' ? proofread : action === 'audit' ? audit : approve; - await fn({ ids }); - createMessage.success(label + '成功'); - handleSuccess(); - }, - }); - } - - function handleProofread() { - handleStatusAction('proofread', '校对'); - } - - function handleAudit() { - handleStatusAction('audit', '审核'); - } - - function handleApprove() { - handleStatusAction('approve', '批准'); - } - function handleDetail(record: Recordable) { openModal(true, { record, isUpdate: true, showFooter: false }); } From fd5205e33eca0d4d439a32407d58e1c05f9241ad Mon Sep 17 00:00:00 2001 From: geht <2947093423@qq.com> Date: Mon, 8 Jun 2026 19:05:29 +0800 Subject: [PATCH 2/7] =?UTF-8?q?=E9=92=89=E9=92=89=E5=AE=A1=E6=89=B9?= =?UTF-8?q?=E9=85=8D=E7=BD=AE=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../jeecg-module-xslmes/doc/代码修改日志 | 45 ++ .../callback/ApprovalCallbackContext.java | 26 +- .../callback/ApprovalCallbackDispatcher.java | 14 + .../callback/IApprovalBizCallback.java | 10 + .../approval/entity/MesXslApprovalRecord.java | 5 + .../MesXslApprovalTraceController.java | 85 ++- .../MesXslIntegrationPlanController.java | 16 + .../ApprovalInstanceStageExtractor.java | 594 ++++++++++++++++++ .../engine/IntegrationActionConfigHelper.java | 76 +++ .../engine/IntegrationOrchestrator.java | 46 +- .../executor/RegistryStageRevertExecutor.java | 14 +- .../executor/RegistryStageSyncExecutor.java | 44 +- .../executor/SqlUpdateActionExecutor.java | 9 +- .../entity/MesXslApprovalTrace.java | 5 + .../service/IApprovalTraceSyncService.java | 14 + .../service/IMesXslApprovalTraceService.java | 40 ++ .../impl/ApprovalTraceSyncServiceImpl.java | 169 ++++- .../impl/MesXslApprovalTraceServiceImpl.java | 552 ++++++++++++++++ .../integration/vo/DingOperationRecordVO.java | 49 ++ .../vo/DingProcessForecastNodeVO.java | 52 ++ .../integration/vo/DingProcessForecastVO.java | 37 ++ .../vo/DingProcessInstanceFlowVO.java | 40 ++ .../MesXslDingProcessTplController.java | 163 +++-- .../DingApprovalLaunchParamBuilder.java | 272 ++++++++ .../stream/DingBpmsEventProcessor.java | 380 ++++++++--- .../stream/DingTalkWorkflowService.java | 405 +++++++++++- .../xslmes/entity/MesXslMixingSpec.java | 8 + .../impl/MesXslMixingSpecServiceImpl.java | 5 + .../system/controller/SysUserController.java | 64 ++ .../jeecg/modules/system/entity/SysUser.java | 7 + ..._xsl_approval_record_node_activity_map.sql | 7 + .../V3.9.2_140__sys_user_ding_user_id.sql | 2 + ...V3.9.2_141__mes_xsl_mixing_spec_status.sql | 31 + .../src/views/system/user/index.vue | 26 +- .../src/views/system/user/user.api.ts | 12 +- .../src/views/system/user/user.data.ts | 8 + .../integration/MesXslApprovalTrace.api.ts | 24 + .../integration/MesXslApprovalTrace.data.ts | 21 +- .../DingApprovalFlowTimelineModal.vue | 203 ++++++ .../components/DingApprovalForecastModal.vue | 102 +++ .../DingApprovalInstanceJsonModal.vue | 89 +++ .../components/MesXslApprovalTraceDrawer.vue | 60 +- .../components/VisualActionEditor.vue | 156 +++-- .../mesXslMixingSpec/MesXslMixingSpec.data.ts | 21 +- 44 files changed, 3730 insertions(+), 278 deletions(-) create mode 100644 jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/integration/engine/ApprovalInstanceStageExtractor.java create mode 100644 jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/integration/engine/IntegrationActionConfigHelper.java create mode 100644 jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/integration/vo/DingOperationRecordVO.java create mode 100644 jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/integration/vo/DingProcessForecastNodeVO.java create mode 100644 jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/integration/vo/DingProcessForecastVO.java create mode 100644 jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/integration/vo/DingProcessInstanceFlowVO.java create mode 100644 jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/dingtalk/service/DingApprovalLaunchParamBuilder.java create mode 100644 jeecg-boot/jeecg-module-system/jeecg-system-start/src/main/resources/flyway/sql/mysql/V3.9.2_139__mes_xsl_approval_record_node_activity_map.sql create mode 100644 jeecg-boot/jeecg-module-system/jeecg-system-start/src/main/resources/flyway/sql/mysql/V3.9.2_140__sys_user_ding_user_id.sql create mode 100644 jeecg-boot/jeecg-module-system/jeecg-system-start/src/main/resources/flyway/sql/mysql/V3.9.2_141__mes_xsl_mixing_spec_status.sql create mode 100644 jeecgboot-vue3/src/views/xslmes/approval/integration/components/DingApprovalFlowTimelineModal.vue create mode 100644 jeecgboot-vue3/src/views/xslmes/approval/integration/components/DingApprovalForecastModal.vue create mode 100644 jeecgboot-vue3/src/views/xslmes/approval/integration/components/DingApprovalInstanceJsonModal.vue diff --git a/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/doc/代码修改日志 b/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/doc/代码修改日志 index 03aaee78..e57fbdca 100644 --- a/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/doc/代码修改日志 +++ b/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/doc/代码修改日志 @@ -820,3 +820,48 @@ jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules 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: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 diff --git a/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/callback/ApprovalCallbackContext.java b/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/callback/ApprovalCallbackContext.java index dba2379f..f79e295c 100644 --- a/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/callback/ApprovalCallbackContext.java +++ b/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/callback/ApprovalCallbackContext.java @@ -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动作,支持撤销时触发业务回滚回调----------- } /** 回调动作 */ @@ -76,6 +81,11 @@ public class ApprovalCallbackContext implements Serializable { /** 操作人姓名 */ 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; @@ -87,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字段,支持钉钉回调时传递真实审批人身份及节点精确定位----------- } diff --git a/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/callback/ApprovalCallbackDispatcher.java b/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/callback/ApprovalCallbackDispatcher.java index c282a5cd..6e779459 100644 --- a/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/callback/ApprovalCallbackDispatcher.java +++ b/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/callback/ApprovalCallbackDispatcher.java @@ -61,6 +61,15 @@ 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)) { @@ -134,6 +143,11 @@ 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; } diff --git a/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/callback/IApprovalBizCallback.java b/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/callback/IApprovalBizCallback.java index 185cf5a4..34d10f1e 100644 --- a/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/callback/IApprovalBizCallback.java +++ b/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/callback/IApprovalBizCallback.java @@ -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时通知业务回滚中间态状态----------- } diff --git a/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/entity/MesXslApprovalRecord.java b/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/entity/MesXslApprovalRecord.java index 5900f0b4..4f745cab 100644 --- a/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/entity/MesXslApprovalRecord.java +++ b/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/entity/MesXslApprovalRecord.java @@ -108,6 +108,11 @@ public class MesXslApprovalRecord extends JeecgEntity implements Serializable { 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; diff --git a/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/integration/controller/MesXslApprovalTraceController.java b/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/integration/controller/MesXslApprovalTraceController.java index c31fb969..b4198fcb 100644 --- a/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/integration/controller/MesXslApprovalTraceController.java +++ b/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/integration/controller/MesXslApprovalTraceController.java @@ -1,5 +1,6 @@ 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; @@ -15,6 +16,8 @@ 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.RequestMapping; @@ -46,7 +49,9 @@ public class MesXslApprovalTraceController extends JeecgController qw = QueryGenerator.initQueryWrapper(model, req.getParameterMap()); qw.orderByDesc("update_time").orderByDesc("create_time"); - return Result.OK(traceService.page(new Page<>(pageNo, pageSize), qw)); + //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查询") @@ -69,4 +74,82 @@ public class MesXslApprovalTraceController extends JeecgController 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 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 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----------- } diff --git a/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/integration/controller/MesXslIntegrationPlanController.java b/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/integration/controller/MesXslIntegrationPlanController.java index afe1d9b3..4cb85653 100644 --- a/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/integration/controller/MesXslIntegrationPlanController.java +++ b/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/integration/controller/MesXslIntegrationPlanController.java @@ -9,6 +9,7 @@ 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; @@ -194,6 +195,7 @@ public class MesXslIntegrationPlanController extends JeecgController addAction(@RequestBody MesXslIntegrationAction action) { + normalizeRegistryAction(action); actionService.save(action); return Result.OK("添加成功"); } @@ -202,10 +204,24 @@ public class MesXslIntegrationPlanController extends JeecgController 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") diff --git a/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/integration/engine/ApprovalInstanceStageExtractor.java b/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/integration/engine/ApprovalInstanceStageExtractor.java new file mode 100644 index 00000000..48ffb948 --- /dev/null +++ b/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/integration/engine/ApprovalInstanceStageExtractor.java @@ -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 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 resolveCompletedStages(JSONObject instance, String flowConfig) { + List completions = new ArrayList<>(); + if (instance == null || oConvertUtils.isEmpty(flowConfig)) { + return completions; + } + List 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> groupTasksByActivityId(JSONObject instance) { + LinkedHashMap> 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 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 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 loadMesApproverNodes(String flowConfig) { + List result = new ArrayList<>(); + if (oConvertUtils.isEmpty(flowConfig)) { + return result; + } + try { + collectAllApproverNodes(JSONObject.parseObject(flowConfig), result); + } catch (Exception ignored) { + // 解析失败返回空列表 + } + return result; + } + + public List alignMesNodesWithTasks(JSONObject instance, String flowConfig) { + List pairs = new ArrayList<>(); + LinkedHashMap> grouped = groupTasksByActivityId(instance); + if (grouped.isEmpty()) { + return pairs; + } + List mesNodes = loadMesApproverNodes(flowConfig); + if (mesNodes.isEmpty()) { + return pairs; + } + List 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 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 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 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 resolveActorNames(List dtUserIds) { + if (dtUserIds == null || dtUserIds.isEmpty()) { + return new ArrayList<>(); + } + Map nameMap = batchResolveDtUserDisplayNames(dtUserIds); + return dtUserIds.stream().map(id -> nameMap.getOrDefault(id, id)).collect(Collectors.toList()); + } + + private NodeTaskDecision decisionFromPendingTasks(List 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 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 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 findAllActedTasks(List taskList, String result) { + List 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 extractOrderedUserIds(List tasks) { + List 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 listAllAssigneeIds(List taskList) { + List 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 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 taskList) { + for (JSONObject task : taskList) { + if (task == null) { + continue; + } + if (!"CANCELED".equalsIgnoreCase(task.getString("status"))) { + return false; + } + } + return true; + } + + private int countActiveTasks(List 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 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 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 batchResolveDtUserDisplayNames(Collection dtUserIds) { + Map result = new HashMap<>(); + if (dtUserIds == null || dtUserIds.isEmpty()) { + return result; + } + List 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> 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 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 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> 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 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 dtUserIds; + } + + @Data + @Accessors(chain = true) + public static class NodePair { + private int stepNo; + private JSONObject mesNode; + private String activityId; + private List taskList; + } + + @Data + @Accessors(chain = true) + public static class NodeTaskDecision { + private String nodeStatus; + private String nodeStatusText; + private List actorUserIds = new ArrayList<>(); + private Date operatorTime; + private boolean agreed; + private boolean refused; + } +} diff --git a/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/integration/engine/IntegrationActionConfigHelper.java b/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/integration/engine/IntegrationActionConfigHelper.java new file mode 100644 index 00000000..c9da34c7 --- /dev/null +++ b/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/integration/engine/IntegrationActionConfigHelper.java @@ -0,0 +1,76 @@ +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); + } + + public static String resolveTargetStage(MesXslIntegrationAction action) { + if (action != null && oConvertUtils.isNotEmpty(action.getActionConfig())) { + try { + JSONObject cfg = JSONObject.parseObject(action.getActionConfig()); + if (oConvertUtils.isNotEmpty(cfg.getString("targetStage"))) { + return cfg.getString("targetStage").trim(); + } + JSONObject registryStage = cfg.getJSONObject("registryStage"); + if (registryStage != null && oConvertUtils.isNotEmpty(registryStage.getString("targetStage"))) { + return registryStage.getString("targetStage").trim(); + } + } catch (Exception ignored) { + // fallback compile + } + } + return "compile"; + } +} diff --git a/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/integration/engine/IntegrationOrchestrator.java b/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/integration/engine/IntegrationOrchestrator.java index bdb092f5..56faa152 100644 --- a/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/integration/engine/IntegrationOrchestrator.java +++ b/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/integration/engine/IntegrationOrchestrator.java @@ -223,6 +223,9 @@ public class IntegrationOrchestrator { 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(); @@ -296,6 +299,15 @@ public class IntegrationOrchestrator { return null; } + //update-begin---author:GHT ---date:20260608 for:【审核集成】多动作串行执行时刷新源单上下文----------- + private void refreshSourceRecord(IntegrationContext ctx) { + Map 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 { @@ -386,18 +398,7 @@ public class IntegrationOrchestrator { } private String resolveRevertTargetStage(MesXslIntegrationAction action) { - String targetStage = "compile"; - if (oConvertUtils.isNotEmpty(action.getActionConfig())) { - try { - JSONObject cfg = JSONObject.parseObject(action.getActionConfig()); - if (oConvertUtils.isNotEmpty(cfg.getString("targetStage"))) { - targetStage = cfg.getString("targetStage").trim(); - } - } catch (Exception ignored) { - // 默认 compile - } - } - return targetStage; + return IntegrationActionConfigHelper.resolveTargetStage(action); } private String readSourceStatus(IntegrationContext ctx) { @@ -480,27 +481,20 @@ public class IntegrationOrchestrator { if (actions == null || actions.isEmpty()) { return false; } - String expectedFrom = resolveExpectedFromFromAction(actions.get(0), plan.getTriggerStage()); + String expectedFrom = resolveExpectedFromFromPlan(actions, plan.getTriggerStage()); if (oConvertUtils.isEmpty(expectedFrom)) { return false; } return expectedFrom.equals(currentStatus); } - private String resolveExpectedFromFromAction(MesXslIntegrationAction action, String triggerStage) { - if (action != null && oConvertUtils.isNotEmpty(action.getActionConfig())) { - try { - JSONObject cfg = JSONObject.parseObject(action.getActionConfig()); - JSONObject registryStage = cfg.getJSONObject("registryStage"); - if (registryStage != null && oConvertUtils.isNotEmpty(registryStage.getString("expectedFrom"))) { - return registryStage.getString("expectedFrom").trim(); + private String resolveExpectedFromFromPlan(List actions, String triggerStage) { + if (actions != null) { + for (MesXslIntegrationAction action : actions) { + String expectedFrom = IntegrationActionConfigHelper.resolveExpectedFrom(action, triggerStage); + if (oConvertUtils.isNotEmpty(expectedFrom)) { + return expectedFrom; } - if (cfg.containsKey("expectedFrom")) { - String v = cfg.getString("expectedFrom"); - return oConvertUtils.isEmpty(v) ? null : v.trim(); - } - } catch (Exception ignored) { - // fallback } } return RegistryStageFieldHelper.defaultExpectedFrom(triggerStage); diff --git a/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/integration/engine/executor/RegistryStageRevertExecutor.java b/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/integration/engine/executor/RegistryStageRevertExecutor.java index b94154cd..5393baf9 100644 --- a/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/integration/engine/executor/RegistryStageRevertExecutor.java +++ b/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/integration/engine/executor/RegistryStageRevertExecutor.java @@ -1,8 +1,8 @@ package org.jeecg.modules.xslmes.approval.integration.engine.executor; -import com.alibaba.fastjson2.JSONObject; import lombok.extern.slf4j.Slf4j; import org.jeecg.common.util.oConvertUtils; +import org.jeecg.modules.xslmes.approval.integration.engine.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; @@ -46,17 +46,7 @@ public class RegistryStageRevertExecutor implements IIntegrationActionExecutor { throw new IllegalStateException("业务表未在审批注册中心启用: " + bizTable); } - String targetStage = "compile"; - if (oConvertUtils.isNotEmpty(action.getActionConfig())) { - try { - JSONObject cfg = JSONObject.parseObject(action.getActionConfig()); - if (oConvertUtils.isNotEmpty(cfg.getString("targetStage"))) { - targetStage = cfg.getString("targetStage").trim(); - } - } catch (Exception ignored) { - // 使用默认 compile - } - } + String targetStage = IntegrationActionConfigHelper.resolveTargetStage(action); String statusField = RegistryStageFieldHelper.statusField(registry); RegistryStageFieldHelper.assertIdentifier(statusField); diff --git a/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/integration/engine/executor/RegistryStageSyncExecutor.java b/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/integration/engine/executor/RegistryStageSyncExecutor.java index fdcfc5e6..f7ce248b 100644 --- a/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/integration/engine/executor/RegistryStageSyncExecutor.java +++ b/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/integration/engine/executor/RegistryStageSyncExecutor.java @@ -1,11 +1,11 @@ package org.jeecg.modules.xslmes.approval.integration.engine.executor; -import com.alibaba.fastjson2.JSONObject; import java.util.Date; import lombok.extern.slf4j.Slf4j; import org.jeecg.common.util.oConvertUtils; import org.jeecg.modules.xslmes.approval.callback.ApprovalCallbackContext; import org.jeecg.modules.xslmes.approval.integration.engine.ApprovalStageResolver; +import org.jeecg.modules.xslmes.approval.integration.engine.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; @@ -69,7 +69,9 @@ public class RegistryStageSyncExecutor implements IIntegrationActionExecutor { } String operator = resolveOperator(ctx); - Date now = new Date(); + //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( @@ -108,37 +110,15 @@ public class RegistryStageSyncExecutor implements IIntegrationActionExecutor { } private String resolveStage(IntegrationContext ctx, MesXslIntegrationAction action) { - if (oConvertUtils.isNotEmpty(action.getActionConfig())) { - try { - JSONObject cfg = JSONObject.parseObject(action.getActionConfig()); - String stage = cfg.getString("stage"); - if (oConvertUtils.isNotEmpty(stage)) { - return stage.trim(); - } - } catch (Exception ignored) { - // 继续 fallback - } - } - MesXslIntegrationPlan plan = ctx.getPlan(); - if (plan != null && oConvertUtils.isNotEmpty(plan.getTriggerStage())) { - return plan.getTriggerStage(); + 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) { - if (oConvertUtils.isNotEmpty(action.getActionConfig())) { - try { - JSONObject cfg = JSONObject.parseObject(action.getActionConfig()); - if (cfg.containsKey("expectedFrom")) { - String v = cfg.getString("expectedFrom"); - return oConvertUtils.isEmpty(v) ? null : v.trim(); - } - } catch (Exception ignored) { - // fallback - } - } - return RegistryStageFieldHelper.defaultExpectedFrom(stage); + return IntegrationActionConfigHelper.resolveExpectedFrom(action, stage); } private String resolveOperator(IntegrationContext ctx) { @@ -151,5 +131,13 @@ public class RegistryStageSyncExecutor implements IIntegrationActionExecutor { } 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】审批注册中心环节同步执行器----------- } diff --git a/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/integration/engine/executor/SqlUpdateActionExecutor.java b/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/integration/engine/executor/SqlUpdateActionExecutor.java index 29859b07..e43ab156 100644 --- a/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/integration/engine/executor/SqlUpdateActionExecutor.java +++ b/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/integration/engine/executor/SqlUpdateActionExecutor.java @@ -52,7 +52,14 @@ public class SqlUpdateActionExecutor implements IIntegrationActionExecutor { log.info("[集成引擎][SQL_UPDATE] 执行 action={} sql={}", action.getActionName(), resolvedSql); int affected = jdbcTemplate.update(resolvedSql); - String result = "影响行数: " + affected; + //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); return result; } diff --git a/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/integration/entity/MesXslApprovalTrace.java b/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/integration/entity/MesXslApprovalTrace.java index f2ba820d..0aefb040 100644 --- a/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/integration/entity/MesXslApprovalTrace.java +++ b/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/integration/entity/MesXslApprovalTrace.java @@ -1,5 +1,6 @@ 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; @@ -37,6 +38,10 @@ public class MesXslApprovalTrace extends JeecgEntity implements Serializable { @Schema(description = "业务单据ID") private String bizDataId; + @TableField(exist = false) + @Schema(description = "钉钉审批实例ID(来自审批台账)") + private String externalInstanceId; + @Schema(description = "校对人") private String proofreadBy; diff --git a/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/integration/service/IApprovalTraceSyncService.java b/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/integration/service/IApprovalTraceSyncService.java index e2f1068e..9cd17c4e 100644 --- a/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/integration/service/IApprovalTraceSyncService.java +++ b/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/integration/service/IApprovalTraceSyncService.java @@ -23,4 +23,18 @@ public interface IApprovalTraceSyncService { * @param targetStage compile / proofread / audit */ 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:【审批注册中心】拒绝/终止时清空源单与痕迹操作人----------- + /** + * 驳回/终止后回退到编制态:清空源单操作人/时间字段并清空痕迹明细 + */ + void revertToCompile(String bizTable, String bizDataId); + //update-end---author:GHT ---date:20260608 for:【审批注册中心】拒绝/终止时清空源单与痕迹操作人----------- } diff --git a/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/integration/service/IMesXslApprovalTraceService.java b/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/integration/service/IMesXslApprovalTraceService.java index 94bdf02a..83bcffb2 100644 --- a/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/integration/service/IMesXslApprovalTraceService.java +++ b/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/integration/service/IMesXslApprovalTraceService.java @@ -1,7 +1,14 @@ 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; /** * 审批痕迹明细 @@ -12,4 +19,37 @@ public interface IMesXslApprovalTraceService extends IService pageWithDingInstanceId(IPage page, Wrapper wrapper); + + /** + * 批量补充钉钉审批实例ID + */ + void enrichExternalInstanceIds(List 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----------- } diff --git a/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/integration/service/impl/ApprovalTraceSyncServiceImpl.java b/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/integration/service/impl/ApprovalTraceSyncServiceImpl.java index 2ad0de81..a4dc5066 100644 --- a/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/integration/service/impl/ApprovalTraceSyncServiceImpl.java +++ b/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/integration/service/impl/ApprovalTraceSyncServiceImpl.java @@ -1,17 +1,27 @@ 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.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.IMesXslApprovalTraceService; 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; @@ -21,6 +31,7 @@ import org.springframework.transaction.annotation.Transactional; * @author GHT * @date 2026-06-05 for:【XSLMES-20260605-K8R2】审批痕迹双写 */ +@Slf4j @Service public class ApprovalTraceSyncServiceImpl implements IApprovalTraceSyncService { @@ -32,7 +43,16 @@ public class ApprovalTraceSyncServiceImpl implements IApprovalTraceSyncService { private IMesXslBizDocRegistryService registryService; @Autowired - private IMesXslApprovalTraceService traceService; + private MesXslApprovalTraceMapper traceMapper; + + @Autowired + private DingTalkWorkflowService dingTalkWorkflowService; + + @Autowired + private ApprovalInstanceStageExtractor instanceStageExtractor; + + @Autowired + private JdbcTemplate jdbcTemplate; @Override public String checkStageAllowed(String bizTable, String stage) { @@ -53,7 +73,7 @@ public class ApprovalTraceSyncServiceImpl implements IApprovalTraceSyncService { if (registry == null || !containsStage(registry.getEnabledStages(), stage)) { return; } - MesXslApprovalTrace trace = traceService.getByBiz(bizTable, bizDataId); + MesXslApprovalTrace trace = findTraceByBiz(bizTable, bizDataId); if (trace == null) { trace = new MesXslApprovalTrace() .setRegistryId(registry.getId()) @@ -77,13 +97,13 @@ public class ApprovalTraceSyncServiceImpl implements IApprovalTraceSyncService { default: return; } - traceService.saveOrUpdate(trace); + saveOrUpdateTrace(trace); } @Override @Transactional(rollbackFor = Exception.class) public void revertToStage(String bizTable, String bizDataId, String targetStage) { - MesXslApprovalTrace trace = traceService.getByBiz(bizTable, bizDataId); + MesXslApprovalTrace trace = findTraceByBiz(bizTable, bizDataId); if (trace == null) { return; } @@ -107,7 +127,144 @@ public class ApprovalTraceSyncServiceImpl implements IApprovalTraceSyncService { } else { return; } - traceService.update(wrapper); + 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 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("")); + } + + private void updateBizStageFields(MesXslBizDocRegistry registry, String bizTable, String bizDataId, StageCompletion completion) { + String stage = completion.getStage(); + String statusField = RegistryStageFieldHelper.statusField(registry); + String byField = RegistryStageFieldHelper.byField(registry, stage); + String timeField = RegistryStageFieldHelper.timeField(registry, stage); + RegistryStageFieldHelper.assertIdentifier(statusField); + if (oConvertUtils.isNotEmpty(byField)) { + RegistryStageFieldHelper.assertIdentifier(byField); + } + if (oConvertUtils.isNotEmpty(timeField)) { + RegistryStageFieldHelper.assertIdentifier(timeField); + } + StringBuilder sql = new StringBuilder("UPDATE `").append(bizTable).append("` SET `") + .append(statusField).append("`=?"); + List params = new ArrayList<>(); + params.add(stage); + if (oConvertUtils.isNotEmpty(byField)) { + sql.append(", `").append(byField).append("`=?"); + params.add(completion.getOperatorBy()); + } + if (oConvertUtils.isNotEmpty(timeField)) { + sql.append(", `").append(timeField).append("`=?"); + params.add(completion.getOperatorTime()); + } + sql.append(" WHERE id=?"); + params.add(bizDataId); + jdbcTemplate.update(sql.toString(), params.toArray()); + } + //update-end---author:GHT ---date:20260608 for:【审批注册中心】按实例tasks反写审批痕迹明细----------- + + //update-begin---author:GHT ---date:20260608 for:【审批注册中心】拒绝/终止时清空源单与痕迹操作人----------- + @Override + @Transactional(rollbackFor = Exception.class) + public void revertToCompile(String bizTable, String bizDataId) { + if (oConvertUtils.isEmpty(bizTable) || oConvertUtils.isEmpty(bizDataId)) { + return; + } + MesXslBizDocRegistry registry = findActiveRegistry(bizTable); + if (registry == null) { + revertToStage(bizTable, bizDataId, "compile"); + return; + } + String statusField = RegistryStageFieldHelper.statusField(registry); + RegistryStageFieldHelper.assertIdentifier(statusField); + RegistryStageFieldHelper.assertIdentifier(bizTable); + StringBuilder sql = new StringBuilder("UPDATE `").append(bizTable).append("` SET `") + .append(statusField).append("`=?"); + List params = new ArrayList<>(); + params.add("compile"); + appendClearField(sql, params, registry.getProofreadByField()); + appendClearField(sql, params, registry.getProofreadTimeField()); + appendClearField(sql, params, registry.getAuditByField()); + appendClearField(sql, params, registry.getAuditTimeField()); + appendClearField(sql, params, registry.getApproveByField()); + appendClearField(sql, params, registry.getApproveTimeField()); + sql.append(" WHERE id=?"); + params.add(bizDataId); + jdbcTemplate.update(sql.toString(), params.toArray()); + revertToStage(bizTable, bizDataId, "compile"); + } + + private void appendClearField(StringBuilder sql, List params, String field) { + if (oConvertUtils.isEmpty(field)) { + return; + } + RegistryStageFieldHelper.assertIdentifier(field); + sql.append(", `").append(field).append("`=?"); + params.add(null); + } + //update-end---author:GHT ---date:20260608 for:【审批注册中心】拒绝/终止时清空源单与痕迹操作人----------- + + private MesXslApprovalTrace findTraceByBiz(String bizTable, String bizDataId) { + if (oConvertUtils.isEmpty(bizTable) || oConvertUtils.isEmpty(bizDataId)) { + return null; + } + return traceMapper.selectOne(new LambdaQueryWrapper() + .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) { diff --git a/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/integration/service/impl/MesXslApprovalTraceServiceImpl.java b/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/integration/service/impl/MesXslApprovalTraceServiceImpl.java index 7f1b3737..265f3921 100644 --- a/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/integration/service/impl/MesXslApprovalTraceServiceImpl.java +++ b/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/integration/service/impl/MesXslApprovalTraceServiceImpl.java @@ -1,19 +1,78 @@ 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.Comparator; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; + /** * 审批痕迹明细 */ +@Slf4j @Service public class MesXslApprovalTraceServiceImpl extends ServiceImpl 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; + @Override public MesXslApprovalTrace getByBiz(String bizTable, String bizDataId) { if (oConvertUtils.isEmpty(bizTable) || oConvertUtils.isEmpty(bizDataId)) { @@ -24,4 +83,497 @@ public class MesXslApprovalTraceServiceImpl extends ServiceImpl pageWithDingInstanceId(IPage page, Wrapper wrapper) { + IPage 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 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 traces) { + if (traces == null || traces.isEmpty()) { + return; + } + Set bizDataIds = traces.stream() + .map(MesXslApprovalTrace::getBizDataId) + .filter(oConvertUtils::isNotEmpty) + .collect(Collectors.toCollection(HashSet::new)); + if (bizDataIds.isEmpty()) { + return; + } + Set bizTables = traces.stream() + .map(MesXslApprovalTrace::getBizTable) + .filter(oConvertUtils::isNotEmpty) + .collect(Collectors.toCollection(HashSet::new)); + if (bizTables.isEmpty()) { + return; + } + List 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 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 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 parseOperationRecords(JSONArray records) { + List 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 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 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 list) { + if (list == null || list.isEmpty()) { + return; + } + Set 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 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 batchResolveDtUserDisplayNames(Collection dtUserIds) { + Map result = new HashMap<>(); + if (dtUserIds == null || dtUserIds.isEmpty()) { + return result; + } + List 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> 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 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 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> 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 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 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 parseInstanceTaskNodes(JSONObject instance, + MesXslApprovalRecord dingRecord, + MesXslApprovalFlow mesFlow) { + String flowConfig = mesFlow == null ? null : mesFlow.getFlowConfig(); + if (oConvertUtils.isEmpty(flowConfig)) { + return new ArrayList<>(); + } + List pairs = instanceStageExtractor.alignMesNodesWithTasks(instance, flowConfig); + if (pairs.isEmpty()) { + return new ArrayList<>(); + } + Map activityNameMap = buildActivityNameMap(instance); + List nodes = new ArrayList<>(); + for (NodePair pair : pairs) { + JSONObject mesNode = pair.getMesNode(); + String activityId = pair.getActivityId(); + List 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 actionerIds = decision.getActorUserIds() == null ? new ArrayList<>() : decision.getActorUserIds(); + List 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 buildActivityNameMap(JSONObject instance) { + Map 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----------- } diff --git a/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/integration/vo/DingOperationRecordVO.java b/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/integration/vo/DingOperationRecordVO.java new file mode 100644 index 00000000..963a11dd --- /dev/null +++ b/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/integration/vo/DingOperationRecordVO.java @@ -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 ccUserIds; + + @Schema(description = "图片URL列表") + private List images; +} diff --git a/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/integration/vo/DingProcessForecastNodeVO.java b/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/integration/vo/DingProcessForecastNodeVO.java new file mode 100644 index 00000000..bc6c8ff9 --- /dev/null +++ b/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/integration/vo/DingProcessForecastNodeVO.java @@ -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 actionerUserIds; + + @Schema(description = "审批人姓名列表(本地映射)") + private List actionerNames; + + @Schema(description = "节点状态(聚合tasks)") + private String nodeStatus; + + @Schema(description = "节点状态中文") + private String nodeStatusText; +} diff --git a/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/integration/vo/DingProcessForecastVO.java b/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/integration/vo/DingProcessForecastVO.java new file mode 100644 index 00000000..cad25b4d --- /dev/null +++ b/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/integration/vo/DingProcessForecastVO.java @@ -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 nodes; +} diff --git a/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/integration/vo/DingProcessInstanceFlowVO.java b/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/integration/vo/DingProcessInstanceFlowVO.java new file mode 100644 index 00000000..120327b7 --- /dev/null +++ b/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/integration/vo/DingProcessInstanceFlowVO.java @@ -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 operationRecords; +} diff --git a/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/dingtalk/controller/MesXslDingProcessTplController.java b/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/dingtalk/controller/MesXslDingProcessTplController.java index 2bc6dcb7..36c5952b 100644 --- a/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/dingtalk/controller/MesXslDingProcessTplController.java +++ b/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/dingtalk/controller/MesXslDingProcessTplController.java @@ -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; @@ -197,19 +198,11 @@ public class MesXslDingProcessTplController extends JeecgController 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 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,请先完成钉钉账号绑定或确认手机号已在企业钉钉中注册"); } @@ -1240,6 +1233,19 @@ public class MesXslDingProcessTplController extends JeecgController result = new LinkedHashMap<>(); @@ -1567,43 +1579,33 @@ public class MesXslDingProcessTplController extends JeecgController 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 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 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 +1679,47 @@ public class MesXslDingProcessTplController extends JeecgController 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 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 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 +1733,68 @@ public class MesXslDingProcessTplController extends JeecgController + * completionAt 计算规则: + * - AND / ONE_BY_ONE:节点需要所有审批人完成,effectiveOps = totalActioners + * - OR / NONE 或单人:首位通过即完成,effectiveOps = 1 + *

+ * 在 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审批配置】手动填表发起钉钉审批----------- } diff --git a/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/dingtalk/service/DingApprovalLaunchParamBuilder.java b/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/dingtalk/service/DingApprovalLaunchParamBuilder.java new file mode 100644 index 00000000..8430fee8 --- /dev/null +++ b/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/dingtalk/service/DingApprovalLaunchParamBuilder.java @@ -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 listMesApproverNodeNames(String flowConfig) { + List names = new ArrayList<>(); + if (oConvertUtils.isEmpty(flowConfig)) { + return names; + } + try { + JSONObject root = JSONObject.parseObject(flowConfig); + List approverNodes = new ArrayList<>(); + collectApproverNodes(root, approverNodes); + LinkedHashSet 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 dtIdCache = new HashMap<>(); + try { + JSONObject root = JSONObject.parseObject(flowConfig); + List approverNodes = new ArrayList<>(); + collectApproverNodes(root, approverNodes); + LinkedHashSet visitedNodeIds = new LinkedHashSet<>(); + List 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 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 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 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 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 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 cache) { + if (cache.containsKey(username)) { + return cache.get(username); + } + String phone = null; + try { + List 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 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 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 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请求体----------- +} diff --git a/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/dingtalk/stream/DingBpmsEventProcessor.java b/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/dingtalk/stream/DingBpmsEventProcessor.java index cd63fcb3..87e41ccb 100644 --- a/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/dingtalk/stream/DingBpmsEventProcessor.java +++ b/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/dingtalk/stream/DingBpmsEventProcessor.java @@ -13,6 +13,8 @@ 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.StageCompletion; import org.jeecg.modules.xslmes.approval.service.IMesXslApprovalRecordService; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.jdbc.core.JdbcTemplate; @@ -58,6 +60,9 @@ public class DingBpmsEventProcessor { @Autowired private JdbcTemplate jdbcTemplate; + @Autowired + private ApprovalInstanceStageExtractor instanceStageExtractor; + // ==================== bpms_instance_change ==================== //update-begin---author:GHT ---date:20260604 for:【钉钉Stream回调】拉取实例详情后精准执行节点回调----- @@ -122,10 +127,22 @@ public class DingBpmsEventProcessor { } //update-end---author:GHT ---date:2026-06-04 for:【20260604】钉钉回调幂等去重:finishByExternalInstance条件为status=RUNNING,0行更新即终态已处理----- + //update-begin---author:GHT ---date:2026-06-08 for:【风险修复-R5】TERMINATED时触发fireCancelled,允许业务回滚中间态----------- if (ApprovalRecordConstants.STATUS_CANCELLED.equals(status)) { - log.info("{} bpms_instance_change 终止态不触发业务回调 instanceId={}", LOG_TAG, processInstanceId); + MesXslApprovalRecord cancelledRecord = findRecord(processInstanceId); + if (cancelledRecord != null && oConvertUtils.isNotEmpty(cancelledRecord.getBizTable())) { + ApprovalCallbackContext cancelCtx = buildContext(cancelledRecord, remark, null); + logCallbackDispatch("fireCancelled", cancelCtx); + try { + callbackDispatcher.fireCancelled(cancelCtx); + } 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); @@ -154,61 +171,84 @@ public class DingBpmsEventProcessor { LOG_TAG, processInstanceId, taskOps.size(), mesNodes.size(), summarizeNodeNames(mesNodes), summarizeTaskOps(taskOps)); - ApprovalCallbackContext ctx = buildContext(record, remark); + // 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)) { - 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); - log.info("{} 终态通过:最后节点 nodeId={} nodeName={} dtUserId={} tokenGenerated={}", - LOG_TAG, lastNode.getString("id"), lastNode.getString("name"), - lastDtUserId, oConvertUtils.isNotEmpty(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); } - //update-begin---author:GHT ---date:20260605 for:【XSLMES-20260605-K8R2】钉钉终态回调补充节点信息供集成引擎匹配环节----------- - if (!mesNodes.isEmpty()) { - JSONObject 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")); } - //update-end---author:GHT ---date:20260605 for:【XSLMES-20260605-K8R2】钉钉终态回调补充节点信息供集成引擎匹配环节----------- + 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 { JSONObject refuseOp = findRefuseOp(taskOps); + JSONObject refuseNode = null; + String refuseDtUserId = null; if (refuseOp != null) { - int refuseIndex = taskOps.indexOf(refuseOp); + 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("{} 终态驳回:refuseIndex={} bizAtOrigin={} originStatus={} currentBizStatus={} refuseOp={}", - LOG_TAG, refuseIndex, bizAtOrigin, record.getOriginStatus(), currentBizStatus, - refuseOp.toJSONString()); - if (!bizAtOrigin && refuseIndex < mesNodes.size()) { - String dtUserId = refuseOp.getString("userId"); - String token = workflowService.generateTokenByDtUserId(dtUserId); - JSONObject refuseNode = mesNodes.get(refuseIndex); - log.info("{} 终态驳回:业务已推进,触发 onReject 集成 nodeId={} nodeName={} tokenGenerated={}", - LOG_TAG, refuseNode.getString("id"), refuseNode.getString("name"), - oConvertUtils.isNotEmpty(token)); - } else { - log.info("{} 终态驳回:跳过业务 onReject(单据仍在发起前状态) instanceId={}", - LOG_TAG, processInstanceId); - } + 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); + log.info("{} 终态驳回:operationRecords 中未找到 REFUSE 记录 instanceId={}", LOG_TAG, processInstanceId); } - if (!mesNodes.isEmpty() && refuseOp != null) { - int refuseIndex = taskOps.indexOf(refuseOp); - if (refuseIndex >= 0 && refuseIndex < mesNodes.size()) { - JSONObject refuseNode = mesNodes.get(refuseIndex); - ctx.setNodeId(refuseNode.getString("id")).setNodeName(refuseNode.getString("name")); - } + 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("{} bpms_instance_change 完成 instanceId={} bizTable={} bizDataId={} mesStatus={}", LOG_TAG, processInstanceId, record.getBizTable(), record.getBizDataId(), status); @@ -226,14 +266,22 @@ public class DingBpmsEventProcessor { 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("{} bpms_task_change 入参 instanceId={} type={} result={} actionerUserId={} payload={}", - LOG_TAG, processInstanceId, type, result, actionerDtUserId, data.toJSONString()); + 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)) { - log.info("{} bpms_task_change 跳过:非节点通过(finish+agree) type={} result={} instanceId={}", - LOG_TAG, type, result, processInstanceId); + if (!"finish".equals(type)) { + log.info("{} bpms_task_change 跳过:type={} 非节点结束事件(finish) instanceId={}", + LOG_TAG, type, processInstanceId); + return; + } + if (!"agree".equals(result)) { + log.info("{} bpms_task_change 跳过:result={} 非同意,refuse/redirect 由 bpms_instance_change 处理 instanceId={}", + LOG_TAG, result, processInstanceId); return; } @@ -248,6 +296,14 @@ public class DingBpmsEventProcessor { 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)); @@ -258,6 +314,14 @@ public class DingBpmsEventProcessor { 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 taskOps = workflowService.getTaskOperations(instance); List mesNodes = loadApproverNodes(record.getFlowId()); @@ -269,59 +333,99 @@ public class DingBpmsEventProcessor { log.info("{} bpms_task_change 跳过:taskOps 为空 instanceId={}", LOG_TAG, processInstanceId); return; } - if (mesNodes.isEmpty()) { - log.info("{} bpms_task_change 跳过:MES 审批节点为空 flowId={} instanceId={}", - LOG_TAG, record.getFlowId(), processInstanceId); + + //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; } - int nodeIndex = taskOps.size() - 1; - if (nodeIndex >= mesNodes.size()) { - log.info("{} bpms_task_change 跳过:节点索引越界 nodeIndex={} mesNodeCount={} instanceId={}", - LOG_TAG, nodeIndex, mesNodes.size(), processInstanceId); + //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,并发安全且重启不丢----- boolean claimed = approvalGateService.tryMarkNodeProcessed(record.getId(), nodeIndex); if (!claimed) { - log.info("{} bpms_task_change 跳过:节点{} 已处理(幂等) instanceId={} recordId={}", - LOG_TAG, nodeIndex + 1, processInstanceId, record.getId()); + log.info("{} bpms_task_change 跳过:节点 activityId={} 已处理(幂等 nodeIndex={}) instanceId={} recordId={}", + LOG_TAG, activityId, nodeIndex, processInstanceId, record.getId()); return; } - log.info("{} 节点幂等占位成功 nodeIndex={} recordId={} instanceId={}", - LOG_TAG, nodeIndex, record.getId(), processInstanceId); + 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,并发安全且重启不丢----- - 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,透传真实审批人身份----------- - //update-begin---author:GHT ---date:20260605 for:【XSLMES-20260605-K8R3】节点映射日志补充 stageKey----------- - JSONObject _nodeProps = node.getJSONObject("props"); - String _stageKeyLog = (_nodeProps != null) ? _nodeProps.getString("stageKey") : null; - log.info("{} 节点映射 nodeIndex={}/{} nodeId={} nodeName={} stageKey={} actioner={} dtUserId={} tokenGenerated={} lastOp={}", - LOG_TAG, nodeIndex + 1, mesNodes.size(), node.getString("id"), node.getString("name"), - _stageKeyLog, actioner, dtUserId, oConvertUtils.isNotEmpty(token), lastOp.toJSONString()); - //update-end---author:GHT ---date:20260605 for:【XSLMES-20260605-K8R3】节点映射日志补充 stageKey----------- + 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)); try { - //update-begin---author:GHT ---date:20260605 for:【XSLMES-20260605-K8R2】钉钉节点回调补充 nodeId/nodeName 供集成方案匹配校对/审核/批准环节----------- - //update-begin---author:GHT ---date:20260605 for:【XSLMES-20260605-K8R3】提取节点 props.stageKey,区分关键环节节点与纯过路审批节点----------- - JSONObject nodeProps = node.getJSONObject("props"); - String stageKey = (nodeProps != null) ? nodeProps.getString("stageKey") : null; - //update-end---author:GHT ---date:20260605 for:【XSLMES-20260605-K8R3】提取节点 props.stageKey,区分关键环节节点与纯过路审批节点----------- - ApprovalCallbackContext ctx = buildContext(record, "钉钉节点审批通过(" + actioner + ")") + ApprovalCallbackContext ctx = buildContext(record, "钉钉节点审批通过(" + actioner + ")", token) .setOperatorName(actioner) + .setOperatorTime(activityCompletion != null ? activityCompletion.getOperatorTime() : null) .setNodeId(node.getString("id")) .setNodeName(node.getString("name")) - .setStageKey(stageKey); - //update-end---author:GHT ---date:20260605 for:【XSLMES-20260605-K8R2】钉钉节点回调补充 nodeId/nodeName 供集成方案匹配校对/审核/批准环节----------- + .setStageKey(stageKey) + .setActivityId(activityId); logCallbackDispatch("fireNodeApproved", ctx); callbackDispatcher.fireNodeApproved(ctx); log.info("{} fireNodeApproved 成功 instanceId={} nodeName={}", LOG_TAG, processInstanceId, ctx.getNodeName()); @@ -330,8 +434,8 @@ public class DingBpmsEventProcessor { LOG_TAG, processInstanceId, node.getString("name"), e.getMessage(), e); } - log.info("{} bpms_task_change 完成 node={}/{} actioner={} bizTable={} bizDataId={} instanceId={}", - LOG_TAG, nodeIndex + 1, mesNodes.size(), actioner, + 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----- @@ -353,40 +457,102 @@ public class DingBpmsEventProcessor { private List loadApproverNodes(String flowId) { List result = new ArrayList<>(); - if (oConvertUtils.isEmpty(flowId)) { - log.info("{} 加载流程节点跳过:flowId 为空", LOG_TAG); - return result; - } + MesXslApprovalFlow flow = loadFlow(flowId); + if (flow == null) return result; try { - MesXslApprovalFlow flow = approvalFlowService.getById(flowId); - if (flow == null || oConvertUtils.isEmpty(flow.getFlowConfig())) { - log.info("{} 加载流程节点跳过:流程不存在或无 flowConfig flowId={}", LOG_TAG, flowId); - return result; - } - collectApproverNodes(JSONObject.parseObject(flow.getFlowConfig()), result); + collectAllApproverNodes(JSONObject.parseObject(flow.getFlowConfig()), result); } catch (Exception e) { log.warn("{} 加载流程节点失败 flowId={}: {}", LOG_TAG, flowId, e.getMessage()); } return result; } - private void collectApproverNodes(JSONObject node, List out) { - if (node == null) { - return; + //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 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); } + /** + * 通过 activityId 在流程树中查找对应的审批节点(遍历所有分支)。 + * + * @return 找到的节点;找不到返回 null + */ + private JSONObject findNodeByActivityId(String flowConfig, String activityId) { + if (oConvertUtils.isEmpty(flowConfig) || oConvertUtils.isEmpty(activityId)) return null; + try { + List 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 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 taskOps) { for (JSONObject op : taskOps) { String r = op.getString("result"); @@ -439,7 +605,8 @@ public class DingBpmsEventProcessor { } } - private ApprovalCallbackContext buildContext(MesXslApprovalRecord record, String comment) { + //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()) @@ -451,8 +618,29 @@ 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) { diff --git a/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/dingtalk/stream/DingTalkWorkflowService.java b/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/dingtalk/stream/DingTalkWorkflowService.java index 34023a9a..b7cf943c 100644 --- a/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/dingtalk/stream/DingTalkWorkflowService.java +++ b/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/dingtalk/stream/DingTalkWorkflowService.java @@ -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; @@ -39,6 +40,9 @@ public class DingTalkWorkflowService { 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; @@ -51,6 +55,9 @@ public class DingTalkWorkflowService { @Autowired private RedisUtil redisUtil; + @Autowired + private ApprovalInstanceStageExtractor instanceStageExtractor; + // ==================== 审批实例详情 ==================== //update-begin---author:GHT ---date:20260604 for:【钉钉Stream回调】拉取钉钉审批实例详情----- @@ -113,6 +120,97 @@ public class DingTalkWorkflowService { } //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: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 解析 ==================== //update-begin---author:GHT ---date:20260604 for:【钉钉Stream回调】从operationRecords提取节点操作序列----- @@ -157,7 +255,10 @@ public class DingTalkWorkflowService { /** * 将钉钉 userId 映射到 MES 系统用户,并生成该用户的 JWT Token。 *

- * 查询链: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 兜底 *

* 这样回调业务接口时,接口内部通过 {@code SecurityUtils.getSubject().getPrincipal()} * 拿到的就是真实审批人,而非 admin,保证 proofread_by/audit_by/approve_by 字段写入正确。 @@ -171,23 +272,36 @@ public class DingTalkWorkflowService { 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 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 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.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成功 dtUserId={} mesUsername={}", LOG_TAG, dtUserId, user.getUsername()); + 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) { @@ -196,6 +310,289 @@ public class DingTalkWorkflowService { } } + // ==================== activityId 辅助方法 ==================== + + //update-begin---author:GHT ---date:2026-06-08 for:【缺陷修复-D2/D3/D4】基于activityId精准定位节点,替代completionAt计数方案----------- + /** + * 从实例 operationRecords 中找到 staffId 对应的最新一条执行记录的 activityId。 + *

+ * 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)。 + *

+ * 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 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 任务。 + *

+ * 用于替代 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 做完成判定。 + *

+ * {@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链路----------- + /** + * 节点是否已完成(可触发集成方案): + *

    + *
  1. 主判定:同 activityId 下所有非取消任务均为 COMPLETED+AGREE(会签全员通过 / 或签实际通过人已通过)
  2. + *
  3. API滞后兜底:当前审批人任务仍 RUNNING,其余均已 AGREE(AND最后一人事件先于API更新到达)
  4. + *
  5. 或签/单人兜底:非AND/ONE_BY_ONE模式时,任意一人AGREE即完成(其他人取消可能短暂延迟)
  6. + *
+ */ + private boolean isNodeCompleteForCallback(JSONObject instance, String activityId, JSONObject mesNode, + String actionerDtUserId) { + if (instance == null || oConvertUtils.isEmpty(activityId)) { + return false; + } + List 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 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 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 filterTasksByActivity(JSONObject instance, String activityId) { + List 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(审批退回)记录。 + *

+ * 存在退回记录时,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 { diff --git a/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/entity/MesXslMixingSpec.java b/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/entity/MesXslMixingSpec.java index 25c9d2a4..e933b458 100644 --- a/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/entity/MesXslMixingSpec.java +++ b/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/entity/MesXslMixingSpec.java @@ -11,6 +11,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,6 +149,13 @@ 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; private String createBy; diff --git a/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/service/impl/MesXslMixingSpecServiceImpl.java b/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/service/impl/MesXslMixingSpecServiceImpl.java index bc1e167a..ab5ddd30 100644 --- a/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/service/impl/MesXslMixingSpecServiceImpl.java +++ b/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/service/impl/MesXslMixingSpecServiceImpl.java @@ -289,6 +289,11 @@ public class MesXslMixingSpecServiceImpl extends ServiceImpl 用户当前密码为默认初始密码,前端需弹出强制修改提示 * 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 syncDingUserId() { + if (thirdAppDingtalkService == null) { + return Result.error("钉钉集成未配置,无法同步"); + } + String accessToken = thirdAppDingtalkService.getAccessTokenForBackground(); + if (oConvertUtils.isEmpty(accessToken)) { + return Result.error("获取钉钉 AccessToken 失败,请检查钉钉应用配置"); + } + // 查询所有有手机号的用户 + List users = sysUserService.list( + new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper() + .isNotNull(SysUser::getPhone) + .ne(SysUser::getPhone, "") + .eq(SysUser::getDelFlag, 0) + ); + int successCount = 0; + int failCount = 0; + List failDetails = new ArrayList<>(); + for (SysUser user : users) { + try { + Response 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 verifyIzDefaultPwd() throws UnsupportedEncodingException { // 未配置 Firewall 或已关闭默认密码检测开关 (enableDefaultPwdCheck=false) 时,直接返回 "no" 表示无需提示 diff --git a/jeecg-boot/jeecg-module-system/jeecg-system-biz/src/main/java/org/jeecg/modules/system/entity/SysUser.java b/jeecg-boot/jeecg-module-system/jeecg-system-biz/src/main/java/org/jeecg/modules/system/entity/SysUser.java index 29ceba0c..ee3c054f 100644 --- a/jeecg-boot/jeecg-module-system/jeecg-system-biz/src/main/java/org/jeecg/modules/system/entity/SysUser.java +++ b/jeecg-boot/jeecg-module-system/jeecg-system-biz/src/main/java/org/jeecg/modules/system/entity/SysUser.java @@ -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功能----------- } diff --git a/jeecg-boot/jeecg-module-system/jeecg-system-start/src/main/resources/flyway/sql/mysql/V3.9.2_139__mes_xsl_approval_record_node_activity_map.sql b/jeecg-boot/jeecg-module-system/jeecg-system-start/src/main/resources/flyway/sql/mysql/V3.9.2_139__mes_xsl_approval_record_node_activity_map.sql new file mode 100644 index 00000000..5cab065a --- /dev/null +++ b/jeecg-boot/jeecg-module-system/jeecg-system-start/src/main/resources/flyway/sql/mysql/V3.9.2_139__mes_xsl_approval_record_node_activity_map.sql @@ -0,0 +1,7 @@ +-- 【XSLMES-20260605-K8R4】台账增加 node_activity_map 字段 +-- 存储 processForecast 结果:JSON数组,每项含 approvalMethod/totalActioners/completionAt +-- completionAt = 累计已处理任务数边界,用于会签/依次审批多人等待完成判断 +SET NAMES utf8mb4; + +ALTER TABLE `mes_xsl_approval_record` + ADD COLUMN `node_activity_map` TEXT NULL COMMENT '钉钉节点活动映射(processForecast结果,JSON数组,含completionAt幂等边界)'; diff --git a/jeecg-boot/jeecg-module-system/jeecg-system-start/src/main/resources/flyway/sql/mysql/V3.9.2_140__sys_user_ding_user_id.sql b/jeecg-boot/jeecg-module-system/jeecg-system-start/src/main/resources/flyway/sql/mysql/V3.9.2_140__sys_user_ding_user_id.sql new file mode 100644 index 00000000..01b264fa --- /dev/null +++ b/jeecg-boot/jeecg-module-system/jeecg-system-start/src/main/resources/flyway/sql/mysql/V3.9.2_140__sys_user_ding_user_id.sql @@ -0,0 +1,2 @@ +-- 用户表新增钉钉用户ID字段 +ALTER TABLE sys_user ADD COLUMN ding_user_id VARCHAR(100) DEFAULT NULL COMMENT '钉钉用户ID'; diff --git a/jeecg-boot/jeecg-module-system/jeecg-system-start/src/main/resources/flyway/sql/mysql/V3.9.2_141__mes_xsl_mixing_spec_status.sql b/jeecg-boot/jeecg-module-system/jeecg-system-start/src/main/resources/flyway/sql/mysql/V3.9.2_141__mes_xsl_mixing_spec_status.sql new file mode 100644 index 00000000..954b6136 --- /dev/null +++ b/jeecg-boot/jeecg-module-system/jeecg-system-start/src/main/resources/flyway/sql/mysql/V3.9.2_141__mes_xsl_mixing_spec_status.sql @@ -0,0 +1,31 @@ +-- 【混炼示方】主表新增状态字段,复用配合示方状态字典 +-- author: cursor date: 2026-06-08 for:【XSLMES-20260608-A01】 +SET NAMES utf8mb4; +SET @db = DATABASE(); + +SET @sql = IF( + (SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = @db AND TABLE_NAME = 'mes_xsl_mixing_spec' AND COLUMN_NAME = 'status') = 0, + 'ALTER TABLE `mes_xsl_mixing_spec` ADD COLUMN `status` varchar(32) DEFAULT ''compile'' COMMENT ''状态(字典xslmes_formula_spec_status)'' AFTER `change_date`', + 'SELECT 1' +); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +-- 按已有审批痕迹回填历史数据状态 +UPDATE `mes_xsl_mixing_spec` SET `status` = 'obsolete' WHERE `del_flag` = 1; +UPDATE `mes_xsl_mixing_spec` SET `status` = 'recognition_pass' WHERE `del_flag` = 0 AND `approve_time` IS NOT NULL; +UPDATE `mes_xsl_mixing_spec` SET `status` = 'review_pass' WHERE `del_flag` = 0 AND `approve_time` IS NULL AND `audit_time` IS NOT NULL; +UPDATE `mes_xsl_mixing_spec` SET `status` = 'submit' WHERE `del_flag` = 0 AND `approve_time` IS NULL AND `audit_time` IS NULL AND `proofread_time` IS NOT NULL; + +-- 混炼示方注册中心默认开启三环节并绑定 status 字段 +UPDATE `mes_xsl_biz_doc_registry` +SET `enabled_stages` = 'proofread,audit,approve', + `status_field` = 'status', + `proofread_by_field` = 'proofread_by', + `proofread_time_field` = 'proofread_time', + `audit_by_field` = 'audit_by', + `audit_time_field` = 'audit_time', + `approve_by_field` = 'approve_by', + `approve_time_field` = 'approve_time', + `update_by` = 'admin', + `update_time` = NOW() +WHERE `doc_code` = 'mixing_spec' AND `del_flag` = 0; diff --git a/jeecgboot-vue3/src/views/system/user/index.vue b/jeecgboot-vue3/src/views/system/user/index.vue index d04d655c..c0106a21 100644 --- a/jeecgboot-vue3/src/views/system/user/index.vue +++ b/jeecgboot-vue3/src/views/system/user/index.vue @@ -9,6 +9,9 @@ 回收站 + + 同步钉钉ID + @@ -229,8 +240,19 @@ option-filter-prop="label" allow-clear size="small" + @change="() => onDetailBizFieldChange(record, tf.componentId)" /> + @@ -288,6 +310,13 @@ import { ref, computed, reactive, onMounted } from 'vue'; import { useMessage } from '/@/hooks/web/useMessage'; import * as Api from './dingTplBind.api'; + import { + type FieldTranslateMeta, + type ValueMode, + VALUE_MODE_OPTIONS, + defaultValueMode, + isTranslatableMeta, + } from './dingTplFieldValue'; const { createMessage } = useMessage(); @@ -317,6 +346,7 @@ componentName: string; parentId?: string; bizField?: string; + valueMode?: ValueMode; children?: FieldRow[]; // 仅 TableField 使用 } @@ -349,14 +379,14 @@ const mainFieldRows = ref([]); const tableFieldRows = ref([]); - const bizFields = ref<{ fieldKey: string; label: string }[]>([]); + const bizFields = ref([]); const bizFieldsLoading = ref(false); const detailSlots = ref([]); const detailSlotsLoading = ref(false); // key = tableField's componentId, value = loaded detail field options - const detailFieldsMap = reactive>({}); + const detailFieldsMap = reactive>({}); const detailFieldsLoadingMap = reactive>({}); const saving = ref(false); @@ -404,15 +434,17 @@ // ══ 表格列定义 ══ const mainColumns = [ - { title: '控件类型', key: 'componentType', width: 140 }, - { title: '钉钉字段名', dataIndex: 'componentLabel', width: 160 }, + { title: '控件类型', key: 'componentType', width: 130 }, + { title: '钉钉字段名', dataIndex: 'componentLabel', width: 140 }, { title: '绑定实体字段', key: 'bizField' }, + { title: '取值方式', key: 'valueMode', width: 140 }, ]; const detailColumns = [ - { title: '控件类型', key: 'componentType', width: 140 }, - { title: '字段名', dataIndex: 'componentLabel', width: 160 }, + { title: '控件类型', key: 'componentType', width: 130 }, + { title: '字段名', dataIndex: 'componentLabel', width: 140 }, { title: '绑定明细字段', key: 'bizField' }, + { title: '取值方式', key: 'valueMode', width: 140 }, ]; // ══ 菜单树操作 ══ @@ -482,7 +514,7 @@ bizFieldsLoading.value = true; try { const list = await Api.getBizFields(bizCode); - bizFields.value = (list || []) as { fieldKey: string; label: string }[]; + bizFields.value = (list || []) as FieldTranslateMeta[]; } catch { bizFields.value = []; } finally { @@ -551,6 +583,11 @@ } } + interface SavedMappingEntry { + bizField?: string; + valueMode?: ValueMode; + } + /** 从 dingFields 构建 mainFieldRows / tableFieldRows */ function buildFieldRows(fields: DingField[], savedMappingJson: string | null) { const savedMap = parseSavedMapping(savedMappingJson); @@ -563,30 +600,46 @@ const cid = f.id || f.label; if (f.componentName === 'TableField') { + const saved = savedMap.get(cid); const tfRow: FieldRow = { componentId: cid, componentLabel: f.label, componentName: f.componentName, - bizField: savedMap.get(cid), + bizField: saved?.bizField, children: (f.children || []).map((child) => { const childCid = `${cid}.${child.id || child.label}`; - return { + const childSaved = savedMap.get(childCid); + const row: FieldRow = { componentId: childCid, componentLabel: child.label, componentName: child.componentName, parentId: cid, - bizField: savedMap.get(childCid), - } as FieldRow; + bizField: childSaved?.bizField, + valueMode: childSaved?.valueMode, + }; + if (row.bizField && !row.valueMode) { + row.valueMode = defaultValueMode( + row.componentName, + findDetailFieldMeta(row.bizField, cid), + ); + } + return row; }), }; tables.push(tfRow); } else { - mains.push({ + const saved = savedMap.get(cid); + const row: FieldRow = { componentId: cid, componentLabel: f.label, componentName: f.componentName, - bizField: savedMap.get(cid), - }); + bizField: saved?.bizField, + valueMode: saved?.valueMode, + }; + if (row.bizField && !row.valueMode) { + row.valueMode = defaultValueMode(row.componentName, findMainFieldMeta(row.bizField)); + } + mains.push(row); } } @@ -594,14 +647,23 @@ tableFieldRows.value = tables; } - /** 解析已保存的 fieldMappingJson → Map */ - function parseSavedMapping(json: string | null): Map { - const m = new Map(); + /** 解析已保存的 fieldMappingJson */ + function parseSavedMapping(json: string | null): Map { + const m = new Map(); if (!json) return m; try { - const arr = JSON.parse(json) as { componentId: string; bizField?: string }[]; + const arr = JSON.parse(json) as { + componentId: string; + bizField?: string; + valueMode?: ValueMode; + }[]; for (const item of arr) { - if (item.componentId) m.set(item.componentId, item.bizField || undefined); + if (item.componentId) { + m.set(item.componentId, { + bizField: item.bizField || undefined, + valueMode: item.valueMode, + }); + } } } catch { /* 解析失败忽略 */ @@ -638,7 +700,7 @@ detailFieldsLoadingMap[tableComponentId] = true; try { const list = await Api.getDetailFields(selectedBizCode.value, slotName, kind); - detailFieldsMap[tableComponentId] = (list || []) as { fieldKey: string; label: string }[]; + detailFieldsMap[tableComponentId] = (list || []) as FieldTranslateMeta[]; } catch { detailFieldsMap[tableComponentId] = []; } finally { @@ -653,6 +715,43 @@ })); } + function findMainFieldMeta(fieldKey?: string): FieldTranslateMeta | undefined { + if (!fieldKey) return undefined; + return bizFields.value.find((f) => f.fieldKey === fieldKey); + } + + function findDetailFieldMeta(fieldKey?: string, tableId?: string): FieldTranslateMeta | undefined { + if (!fieldKey || !tableId) return undefined; + return (detailFieldsMap[tableId] || []).find((f) => f.fieldKey === fieldKey); + } + + function isTranslatableMainField(fieldKey?: string): boolean { + return isTranslatableMeta(findMainFieldMeta(fieldKey)); + } + + function isTranslatableDetailField(fieldKey?: string, tableId?: string): boolean { + return isTranslatableMeta(findDetailFieldMeta(fieldKey, tableId)); + } + + function onMainBizFieldChange(record: FieldRow) { + if (!record.bizField) { + record.valueMode = undefined; + return; + } + record.valueMode = defaultValueMode(record.componentName, findMainFieldMeta(record.bizField)); + } + + function onDetailBizFieldChange(record: FieldRow, tableId: string) { + if (!record.bizField) { + record.valueMode = undefined; + return; + } + record.valueMode = defaultValueMode( + record.componentName, + findDetailFieldMeta(record.bizField, tableId), + ); + } + // ══ 自动匹配 ══ function autoMatchFields() { @@ -727,15 +826,20 @@ componentName: string; parentId?: string; bizField?: string; + valueMode?: ValueMode; }[] = []; for (const row of mainFieldRows.value) { - items.push({ + const item: (typeof items)[0] = { componentId: row.componentId, componentLabel: row.componentLabel, componentName: row.componentName, bizField: row.bizField || '', - }); + }; + if (row.bizField && isTranslatableMainField(row.bizField) && row.valueMode) { + item.valueMode = row.valueMode; + } + items.push(item); } for (const tf of tableFieldRows.value) { @@ -746,13 +850,21 @@ bizField: tf.bizField || '', }); for (const child of tf.children || []) { - items.push({ + const childItem: (typeof items)[0] = { componentId: child.componentId, componentLabel: child.componentLabel, componentName: child.componentName, parentId: tf.componentId, bizField: child.bizField || '', - }); + }; + if ( + child.bizField && + isTranslatableDetailField(child.bizField, tf.componentId) && + child.valueMode + ) { + childItem.valueMode = child.valueMode; + } + items.push(childItem); } } @@ -1063,7 +1175,12 @@ } .dtb-bind-table { - margin-bottom: 4px; + margin-bottom: 8px; + } + + .dtb-value-mode-hint { + color: #bbb; + font-size: 12px; } .dtb-bind-table :deep(.ant-table-cell) { diff --git a/jeecgboot-vue3/src/views/xslmes/dingtalk/mesXslDingProcessTpl/MesXslDingProcessTpl.api.ts b/jeecgboot-vue3/src/views/xslmes/dingtalk/mesXslDingProcessTpl/MesXslDingProcessTpl.api.ts index be4ffcd3..170023dd 100644 --- a/jeecgboot-vue3/src/views/xslmes/dingtalk/mesXslDingProcessTpl/MesXslDingProcessTpl.api.ts +++ b/jeecgboot-vue3/src/views/xslmes/dingtalk/mesXslDingProcessTpl/MesXslDingProcessTpl.api.ts @@ -22,6 +22,7 @@ enum Api { bindFlow = '/xslmes/mesXslDingProcessTpl/bindFlow', approvalFlowList = '/xslmes/approvalFlow/list', previewFlowApprovers = '/xslmes/mesXslDingProcessTpl/previewFlowApprovers', + toggleStatus = '/xslmes/mesXslDingProcessTpl/toggleStatus', } export const getExportUrl = Api.exportXls; @@ -45,7 +46,7 @@ export const batchDelete = (params, handleSuccess) => { }; export const saveOrUpdate = (params, isUpdate) => - defHttp.post({ url: isUpdate ? Api.edit : Api.save, params }, { successMessageMode: 'none' }); + defHttp.post({ url: isUpdate ? Api.edit : Api.save, params }, { successMessageMode: isUpdate ? 'message' : 'none' }); /** 新增审批模板草稿(返回含 id 的完整记录) */ export const addNewTemplate = (params) => @@ -81,3 +82,7 @@ export const getApprovalFlowList = (params?) => export const previewFlowApprovers = (flowId: string) => defHttp.get({ url: Api.previewFlowApprovers, params: { flowId } }, { successMessageMode: 'none' }); + +/** 切换模板启用/停用(停用后绑定的业务页不再显示钉钉审批按钮) */ +export const toggleTplStatus = (id: string) => + defHttp.post({ url: Api.toggleStatus, params: { id } }, { joinParamsToUrl: true }); diff --git a/jeecgboot-vue3/src/views/xslmes/dingtalk/mesXslDingProcessTpl/MesXslDingProcessTplList.vue b/jeecgboot-vue3/src/views/xslmes/dingtalk/mesXslDingProcessTpl/MesXslDingProcessTplList.vue index 7c507115..2ad12f41 100644 --- a/jeecgboot-vue3/src/views/xslmes/dingtalk/mesXslDingProcessTpl/MesXslDingProcessTplList.vue +++ b/jeecgboot-vue3/src/views/xslmes/dingtalk/mesXslDingProcessTpl/MesXslDingProcessTplList.vue @@ -105,6 +105,10 @@ + + + + ({}); @@ -167,6 +174,9 @@ api: list, columns, canResize: true, + //update-begin---author:GHT ---date:2026-06-10 for:【钉钉审批模板】操作列加宽避免按钮挤压----------- + scroll: { x: 1700 }, + //update-end---author:GHT ---date:2026-06-10 for:【钉钉审批模板】操作列加宽避免按钮挤压----------- formConfig: { schemas: searchFormSchema, autoSubmitOnEnter: true, @@ -175,7 +185,9 @@ actionColumn: { title: '操作', dataIndex: 'action', - width: 220, + //update-begin---author:GHT ---date:2026-06-10 for:【钉钉审批模板】操作列加宽避免按钮挤压----------- + width: 540, + //update-end---author:GHT ---date:2026-06-10 for:【钉钉审批模板】操作列加宽避免按钮挤压----------- fixed: 'right', slots: { customRender: 'action' }, }, @@ -230,27 +242,72 @@ (selectedRowKeys.value = []) && reload(); } + function isTplEnabled(record: Recordable) { + return record.status === '1' || record.status === 1; + } + + async function handleToggleStatus(record: Recordable) { + try { + const msg = await toggleTplStatus(record.id); + createMessage.success(typeof msg === 'string' ? msg : isTplEnabled(record) ? '已停用' : '已启用'); + reload(); + } catch (e: any) { + createMessage.error(e?.message || '操作失败'); + } + } + function getTableAction(record) { + const enabled = isTplEnabled(record); return [ { label: '编辑', onClick: handleEdit.bind(null, record), auth: 'xslmes:mes_xsl_ding_process_tpl:edit' }, - //update-begin---author:GHT ---date:2026-06-03 for:【MESToDing审批配置】操作列新增发起审批按钮 + //update-begin---author:GHT ---date:20260610 for:【钉钉审批模板】操作列停用/启用----------- { - label: '发起审批', + label: enabled ? '停用' : '启用', + color: enabled ? 'warning' : 'success', + auth: 'xslmes:mes_xsl_ding_process_tpl:edit', + popConfirm: { + title: enabled + ? '停用后,已绑定该模板的业务页面将不再显示「钉钉审批」按钮,确认停用?' + : '确认启用该审批模板?启用后业务页将恢复显示钉钉审批按钮。', + confirm: handleToggleStatus.bind(null, record), + placement: 'topLeft', + }, + }, + //update-end---author:GHT ---date:20260610 for:【钉钉审批模板】操作列停用/启用----------- + //update-begin---author:GHT ---date:20260610 for:【MESToDing审批配置】操作列绑定审批流程 + { + label: '绑定审批流程', + icon: 'ant-design:apartment-outlined', + auth: 'xslmes:mes_xsl_ding_process_tpl:edit', + onClick: handleBindApprovalFlow.bind(null, record), + }, + //update-end---author:GHT ---date:20260610 for:【MESToDing审批配置】操作列绑定审批流程 + //update-begin---author:GHT ---date:2026-06-10 for:【MESToDing审批配置】设计模板移至操作列、发起审批改名为测试审批 + { + label: '设计模板', + icon: 'ant-design:layout-outlined', + auth: 'xslmes:mes_xsl_ding_process_tpl:edit', + onClick: handleDesignTemplate.bind(null, record), + }, + { + label: '测试审批', icon: 'ant-design:send-outlined', color: 'success', - disabled: !record.processCode, - tooltip: record.processCode ? '手动填表后发起钉钉审批' : '请先配置 processCode', + disabled: !enabled || !record.processCode, + tooltip: !enabled + ? '模板已停用' + : record.processCode + ? '手动填表后测试发起钉钉审批' + : '请先配置 processCode', onClick: handleLaunchApproval.bind(null, record), }, - //update-end---author:GHT ---date:2026-06-03 for:【MESToDing审批配置】操作列新增发起审批按钮 + //update-end---author:GHT ---date:2026-06-10 for:【MESToDing审批配置】设计模板移至操作列、发起审批改名为测试审批 ]; } function getDropDownAction(record) { const actions: any[] = [ { label: '详情', onClick: handleDetail.bind(null, record) }, - //update-begin---author:GHT ---date:2026-06-03 for:【MESToDing审批配置】新增设计模板入口 - { label: '设计模板', onClick: handleDesignTemplate.bind(null, record), icon: 'ant-design:layout-outlined' }, ]; if (!record.processCode) { actions.push({ @@ -262,7 +319,6 @@ } actions.push( { label: '查看钉钉字段', onClick: handleShowDingSchema.bind(null, record), icon: 'ant-design:dingtalk-outlined' }, - //update-end---author:GHT ---date:2026-06-03 for:【MESToDing审批配置】新增设计模板入口 { label: '删除', popConfirm: { title: '是否确认删除', confirm: handleDelete.bind(null, record), placement: 'topLeft' }, @@ -272,11 +328,24 @@ return actions; } + // ===== 绑定审批流程 ===== + //update-begin---author:GHT ---date:20260610 for:【MESToDing审批配置】操作列绑定审批流程 + const bindFlowModalRef = ref(); + + function handleBindApprovalFlow(record: Recordable) { + bindFlowModalRef.value?.open(record); + } + //update-end---author:GHT ---date:20260610 for:【MESToDing审批配置】操作列绑定审批流程 + // ===== 手动填表发起钉钉审批 ===== //update-begin---author:GHT ---date:2026-06-03 for:【MESToDing审批配置】手动填表发起钉钉审批 const launchModalRef = ref(); function handleLaunchApproval(record: Recordable) { + if (!isTplEnabled(record)) { + createMessage.warning('该模板已停用,请先启用后再发起审批'); + return; + } if (!record.processCode) { createMessage.warning('该模板尚未配置 processCode,请先完成模板配置'); return; diff --git a/jeecgboot-vue3/src/views/xslmes/dingtalk/mesXslDingProcessTpl/components/BindApprovalFlowModal.vue b/jeecgboot-vue3/src/views/xslmes/dingtalk/mesXslDingProcessTpl/components/BindApprovalFlowModal.vue new file mode 100644 index 00000000..75344e0b --- /dev/null +++ b/jeecgboot-vue3/src/views/xslmes/dingtalk/mesXslDingProcessTpl/components/BindApprovalFlowModal.vue @@ -0,0 +1,433 @@ + + + + + + + + diff --git a/jeecgboot-vue3/src/views/xslmes/dingtalk/mesXslDingProcessTpl/components/DingTplDesigner.vue b/jeecgboot-vue3/src/views/xslmes/dingtalk/mesXslDingProcessTpl/components/DingTplDesigner.vue index c89dc211..befccf7b 100644 --- a/jeecgboot-vue3/src/views/xslmes/dingtalk/mesXslDingProcessTpl/components/DingTplDesigner.vue +++ b/jeecgboot-vue3/src/views/xslmes/dingtalk/mesXslDingProcessTpl/components/DingTplDesigner.vue @@ -676,6 +676,9 @@ try { const detail = await getTemplateDetail(record.id); tplData.value = detail; + if (detail?.dingNameSynced) { + createMessage.info('已从钉钉同步最新模板名称'); + } if (detail?.dingFields?.length) { // 以钉钉最新 schema 为准(保证结构与钉钉同步) diff --git a/jeecgboot-vue3/src/views/xslmes/mesXslFormulaSpec/MesXslFormulaSpec.data.ts b/jeecgboot-vue3/src/views/xslmes/mesXslFormulaSpec/MesXslFormulaSpec.data.ts index 90bdf7c4..b12c7b2e 100644 --- a/jeecgboot-vue3/src/views/xslmes/mesXslFormulaSpec/MesXslFormulaSpec.data.ts +++ b/jeecgboot-vue3/src/views/xslmes/mesXslFormulaSpec/MesXslFormulaSpec.data.ts @@ -445,7 +445,14 @@ function sectionTitle(label: string, field: string): FormSchema { } const hasWorkflowInfo = ({ values }) => - !!(values.proofreadBy || values.proofreadTime || values.auditBy || values.auditTime || values.approveBy || values.approveTime); + !!( + values.traceProofreadBy || + values.traceProofreadTime || + values.traceAuditBy || + values.traceAuditTime || + values.traceApproveBy || + values.traceApproveTime + ); export const columns: BasicColumn[] = [ { title: '示方编号', align: 'center', dataIndex: 'specCode', width: 150, fixed: 'left' }, @@ -468,8 +475,7 @@ export const columns: BasicColumn[] = [ width: 100, customRender: ({ record }) => record?.createBy_dictText || record?.createBy || '', }, - { title: '审核人', align: 'center', dataIndex: 'auditBy', width: 100, defaultHidden: true }, - { title: '批准人', align: 'center', dataIndex: 'approveBy', width: 100, defaultHidden: true }, + // 审批痕迹 6 列(校对人/校对时间/审核人/审核时间/批准人/批准时间)由 useListPage 统一从 traceColumns 追加,勿在此手写 trace* 列 { title: '状态', align: 'center', dataIndex: 'status_dictText', width: 120 }, { title: '混合段数', align: 'center', dataIndex: 'mixingStages', width: 90, defaultHidden: true }, { title: 'TOTAL PHR', align: 'center', dataIndex: 'totalPhr', width: 100, defaultHidden: true }, @@ -617,51 +623,51 @@ export const workflowFormSchema: FormSchema[] = [ sectionTitle('审批记录', 'dividerWorkflow'), { label: '校对人', - field: 'proofreadBy', + field: 'traceProofreadBy', component: 'Input', componentProps: { disabled: true, bordered: false }, colProps: colHalf, - ifShow: ({ values }) => !!values.proofreadBy, + ifShow: ({ values }) => !!values.traceProofreadBy, }, { label: '校对时间', - field: 'proofreadTime', + field: 'traceProofreadTime', component: 'Input', componentProps: { disabled: true, bordered: false }, colProps: colHalf, - ifShow: ({ values }) => !!values.proofreadTime, + ifShow: ({ values }) => !!values.traceProofreadTime, }, { label: '审核人', - field: 'auditBy', + field: 'traceAuditBy', component: 'Input', componentProps: { disabled: true, bordered: false }, colProps: colHalf, - ifShow: ({ values }) => !!values.auditBy, + ifShow: ({ values }) => !!values.traceAuditBy, }, { label: '审核时间', - field: 'auditTime', + field: 'traceAuditTime', component: 'Input', componentProps: { disabled: true, bordered: false }, colProps: colHalf, - ifShow: ({ values }) => !!values.auditTime, + ifShow: ({ values }) => !!values.traceAuditTime, }, { label: '批准人', - field: 'approveBy', + field: 'traceApproveBy', component: 'Input', componentProps: { disabled: true, bordered: false }, colProps: colHalf, - ifShow: ({ values }) => !!values.approveBy, + ifShow: ({ values }) => !!values.traceApproveBy, }, { label: '批准时间', - field: 'approveTime', + field: 'traceApproveTime', component: 'Input', componentProps: { disabled: true, bordered: false }, colProps: colHalf, - ifShow: ({ values }) => !!values.approveTime, + ifShow: ({ values }) => !!values.traceApproveTime, }, ]; diff --git a/jeecgboot-vue3/src/views/xslmes/mesXslFormulaSpec/components/MesXslFormulaSpecModal.vue b/jeecgboot-vue3/src/views/xslmes/mesXslFormulaSpec/components/MesXslFormulaSpecModal.vue index c1ab527f..a1e55aa0 100644 --- a/jeecgboot-vue3/src/views/xslmes/mesXslFormulaSpec/components/MesXslFormulaSpecModal.vue +++ b/jeecgboot-vue3/src/views/xslmes/mesXslFormulaSpec/components/MesXslFormulaSpecModal.vue @@ -450,6 +450,11 @@ workflowFormSchema, } from '../MesXslFormulaSpec.data'; import { saveOrUpdate, queryById, generateRubberCode as generateRubberCodeApi, getRubberContentSetting } from '../MesXslFormulaSpec.api'; + import { + FORMULA_SPEC_BIZ_TABLE, + hasTraceWorkflowInfo, + loadRecordWithTrace, + } from '/@/views/xslmes/approval/integration/traceRecordHelper'; import MesXslFormulaRubberContentSettingModal from './MesXslFormulaRubberContentSettingModal.vue'; import MesXslFormulaGenerateMixingModal from './MesXslFormulaGenerateMixingModal.vue'; import MesXslFormulaLineColumnSetting from './MesXslFormulaLineColumnSetting.vue'; @@ -464,9 +469,9 @@ const CATEGORY_DICT_CODE = 'xslmes_formula_spec_category'; const WORKFLOW_HEADER_DEFS = [ { key: 'compile', label: '编制', operatorField: 'createBy', operatorTextField: 'createBy_dictText' }, - { key: 'proofread', label: '校对', operatorField: 'proofreadBy', operatorTextField: 'proofreadBy_dictText' }, - { key: 'audit', label: '审核', operatorField: 'auditBy', operatorTextField: 'auditBy_dictText' }, - { key: 'approve', label: '批准', operatorField: 'approveBy', operatorTextField: 'approveBy_dictText' }, + { key: 'proofread', label: '校对', operatorField: 'traceProofreadBy', operatorTextField: 'traceProofreadBy_dictText' }, + { key: 'audit', label: '审核', operatorField: 'traceAuditBy', operatorTextField: 'traceAuditBy_dictText' }, + { key: 'approve', label: '批准', operatorField: 'traceApproveBy', operatorTextField: 'traceApproveBy_dictText' }, ] as const; const modalWidth = '96%'; @@ -681,6 +686,7 @@ const userInfo = userStore.getUserInfo || {}; const text = record?.[step.operatorTextField]; const raw = record?.[step.operatorField]; + // 编制:无 createBy 时回退当前登录人;校对/审核/批准仅展示痕迹表数据,无则留空 if (step.key === 'compile') { return resolveFormulaSpecUserDisplayName(raw, text, userInfo); } @@ -688,11 +694,15 @@ return String(text); } if (raw != null && raw !== '') { - return String(raw); + return resolveFormulaSpecUserDisplayName(raw, null, userInfo); } return ''; } + function loadMainRecordWithTrace(id: string, listRecord?: Recordable) { + return loadRecordWithTrace(id, FORMULA_SPEC_BIZ_TABLE, queryById, listRecord); + } + function formatCategoryShortLabel(text?: string) { if (!text) { return ''; @@ -930,14 +940,7 @@ } function hasWorkflowData(record: Recordable) { - return !!( - record?.proofreadBy || - record?.proofreadTime || - record?.auditBy || - record?.auditTime || - record?.approveBy || - record?.approveTime - ); + return hasTraceWorkflowInfo(record); } function resetFooterValues() { @@ -1133,8 +1136,7 @@ if (unref(isUpdate) && data?.record?.id) { lineLoading.value = true; try { - const mainRaw = await queryById({ id: data.record.id }); - const m = (mainRaw as any)?.id != null ? mainRaw : (mainRaw as any)?.result ?? mainRaw; + const m = await loadMainRecordWithTrace(data.record.id, data.record); applyMixingStages(m?.mixingStages); currentStatus.value = m?.status || 'compile'; const lines = m?.lineList?.length ? normalizeLineRows(m.lineList) : createEmptyLineRows(); diff --git a/jeecgboot-vue3/src/views/xslmes/mesXslMixingSpec/MesXslMixingSpec.data.ts b/jeecgboot-vue3/src/views/xslmes/mesXslMixingSpec/MesXslMixingSpec.data.ts index 70e34f3c..629cb928 100644 --- a/jeecgboot-vue3/src/views/xslmes/mesXslMixingSpec/MesXslMixingSpec.data.ts +++ b/jeecgboot-vue3/src/views/xslmes/mesXslMixingSpec/MesXslMixingSpec.data.ts @@ -694,11 +694,32 @@ export const downStepColumns: JVxeColumn[] = [...stepColumns]; //update-begin---author:cursor ---date:20260522 for:【XSLMES-20260522-A19】TCU温度条件表列宽可调且表头换行----------- /** TCU 温度条件明细列宽偏好 localStorage 键 */ -export const MIXING_TCU_COLUMN_WIDTH_CACHE_KEY = 'mes_xsl_mixing_spec_tcu_column_widths_v2'; +export const MIXING_TCU_COLUMN_WIDTH_CACHE_KEY = 'mes_xsl_mixing_spec_tcu_column_widths_v3'; /** TCU 温度条件明细列可缩小到的最小宽度 */ export const MIXING_TCU_MIN_COLUMN_WIDTH = 48; +/** TCU 是否附加:否 */ +export const MIXING_TCU_ATTACH_NO = '0'; + +/** TCU 是否附加:是 */ +export const MIXING_TCU_ATTACH_YES = '1'; + +/** 判断 TCU 行是否允许维护附加重量 */ +export function isMixingTcuAttachEnabled(value: unknown): boolean { + return value === 1 || value === '1' || value === true; +} + +/** 规范化 TCU 行是否附加/重量联动 */ +export function normalizeMixingTcuAttachRow(row: Recordable) { + if (row.isAttach == null || row.isAttach === '') { + row.isAttach = MIXING_TCU_ATTACH_NO; + } + if (!isMixingTcuAttachEnabled(row.isAttach)) { + row.attachWeight = undefined; + } +} + export const tcuColumns: JVxeColumn[] = [ //update-begin---author:cursor ---date:20260522 for:【XSLMES-20260522-A33】TCU区分固定上/下密炼机----------- { title: '区分', key: 'sectionType', type: JVxeTypes.select, dictCode: 'xslmes_mixing_tcu_section', disabled: true, width: 96, minWidth: MIXING_TCU_MIN_COLUMN_WIDTH, align: 'center' }, @@ -709,6 +730,29 @@ export const tcuColumns: JVxeColumn[] = [ { title: '后混炼室温度', key: 'rearChamberTemp', type: JVxeTypes.inputNumber, width: 76, minWidth: MIXING_TCU_MIN_COLUMN_WIDTH, align: 'center' }, { title: '上下顶栓温度', key: 'topPlugTemp', type: JVxeTypes.inputNumber, width: 76, minWidth: MIXING_TCU_MIN_COLUMN_WIDTH, align: 'center' }, { title: '药品称量位置', key: 'drugWeighPos', type: JVxeTypes.select, dictCode: 'xslmes_mixing_drug_weigh_pos', width: 76, minWidth: MIXING_TCU_MIN_COLUMN_WIDTH, align: 'center' }, + //update-begin---author:GHT ---date:2026-06-10 for:【混炼示方】TCU温度条件新增是否附加/重量----------- + { + title: '是否附加', + key: 'isAttach', + type: JVxeTypes.select, + dictCode: 'yn', + defaultValue: MIXING_TCU_ATTACH_NO, + width: 76, + minWidth: MIXING_TCU_MIN_COLUMN_WIDTH, + align: 'center', + }, + { + title: '重量', + key: 'attachWeight', + type: JVxeTypes.inputNumber, + width: 76, + minWidth: MIXING_TCU_MIN_COLUMN_WIDTH, + align: 'center', + props: { + isDisabledCell: ({ row }: { row?: Recordable }) => !isMixingTcuAttachEnabled(row?.isAttach), + }, + }, + //update-end---author:GHT ---date:2026-06-10 for:【混炼示方】TCU温度条件新增是否附加/重量----------- ]; /** 读取已保存的 TCU 温度条件明细列宽 */ @@ -1012,11 +1056,15 @@ export const MIXING_TCU_UP_MIXER_DRUG_WEIGH_POS = 'drug_scale'; export function buildDefaultMixingTcuRows(rows: Recordable[] = []): Recordable[] { const up = rows.find((r) => r.sectionType === 'up_mixer') || - ({ sectionType: 'up_mixer', drugWeighPos: MIXING_TCU_UP_MIXER_DRUG_WEIGH_POS } as Recordable); + ({ sectionType: 'up_mixer', drugWeighPos: MIXING_TCU_UP_MIXER_DRUG_WEIGH_POS, isAttach: MIXING_TCU_ATTACH_NO } as Recordable); if (up.sectionType === 'up_mixer' && !up.drugWeighPos) { up.drugWeighPos = MIXING_TCU_UP_MIXER_DRUG_WEIGH_POS; } - const down = rows.find((r) => r.sectionType === 'down_mixer') || ({ sectionType: 'down_mixer', drugWeighPos: undefined } as Recordable); + const down = + rows.find((r) => r.sectionType === 'down_mixer') || + ({ sectionType: 'down_mixer', drugWeighPos: undefined, isAttach: MIXING_TCU_ATTACH_NO } as Recordable); + normalizeMixingTcuAttachRow(up); + normalizeMixingTcuAttachRow(down); return [up, down]; } //update-end---author:cursor ---date:20260522 for:【XSLMES-20260522-A33】TCU默认两行及上密炼机药品称默认值----------- diff --git a/jeecgboot-vue3/src/views/xslmes/mesXslMixingSpec/components/MesXslMixingSpecModal.vue b/jeecgboot-vue3/src/views/xslmes/mesXslMixingSpec/components/MesXslMixingSpecModal.vue index 186faa24..e0736652 100644 --- a/jeecgboot-vue3/src/views/xslmes/mesXslMixingSpec/components/MesXslMixingSpecModal.vue +++ b/jeecgboot-vue3/src/views/xslmes/mesXslMixingSpec/components/MesXslMixingSpecModal.vue @@ -510,6 +510,8 @@ import { DEFAULT_MIXING_STEP_ROW_COUNT, DEFAULT_MIXING_DOWN_STEP_ROW_COUNT, buildDefaultMixingTcuRows, + isMixingTcuAttachEnabled, + MIXING_TCU_ATTACH_NO, applyMixingMaterialFromSelection, fillMixingMaterialAccumWeight, calcMixingMaterialUnitWeightTotal, @@ -534,6 +536,8 @@ import { MIXING_STEP_MIN_COLUMN_WIDTH, } from '../MesXslMixingSpec.data'; import { saveOrUpdate, queryById } from '../MesXslMixingSpec.api'; +import { resolveFormulaSpecUserDisplayName } from '/@/views/xslmes/mesXslFormulaSpec/MesXslFormulaSpec.data'; +import { MIXING_SPEC_BIZ_TABLE, loadRecordWithTrace } from '/@/views/xslmes/approval/integration/traceRecordHelper'; import MesXslMixingMaterialColumnSetting from './MesXslMixingMaterialColumnSetting.vue'; import MesXslMixingTableRowHeightSetting from './MesXslMixingTableRowHeightSetting.vue'; import MesXslMixingStepSelectCell from './MesXslMixingStepSelectCell.vue'; @@ -949,15 +953,23 @@ function formatSignDate(value?: string) { } function refreshSignDisplay(row: Recordable = {}) { - signDisplay.draftBy = row.draftBy || row.createBy_dictText || row.createBy || ''; + const userInfo = userStore.getUserInfo || {}; + //update-begin---author:GHT ---date:2026-06-10 for:【混炼示方】页脚起草人/变更人展示姓名----------- + signDisplay.draftBy = resolveFormulaSpecUserDisplayName( + row.draftBy || row.createBy, + row.draftBy_dictText || row.createBy_dictText, + userInfo, + ); + signDisplay.changeBy = resolveFormulaSpecUserDisplayName(row.updateBy, row.updateBy_dictText, userInfo); + //update-end---author:GHT ---date:2026-06-10 for:【混炼示方】页脚起草人/变更人展示姓名----------- signDisplay.draftTime = formatSignDateTime(row.draftTime || row.createTime); - signDisplay.proofreadBy = row.proofreadBy || row.proofreadBy_dictText || ''; - signDisplay.proofreadTime = formatSignDateTime(row.proofreadTime); - signDisplay.auditBy = row.auditBy || row.auditBy_dictText || ''; - signDisplay.auditTime = formatSignDateTime(row.auditTime); - signDisplay.approveBy = row.approveBy || row.approveBy_dictText || ''; - signDisplay.approveTime = formatSignDateTime(row.approveTime); - signDisplay.changeBy = row.updateBy_dictText || row.updateBy || ''; + // 校对/审核/批准:优先展示痕迹表注入字段 + signDisplay.proofreadBy = row.traceProofreadBy || ''; + signDisplay.proofreadTime = formatSignDateTime(row.traceProofreadTime); + signDisplay.auditBy = row.traceAuditBy || ''; + signDisplay.auditTime = formatSignDateTime(row.traceAuditTime); + signDisplay.approveBy = row.traceApproveBy || ''; + signDisplay.approveTime = formatSignDateTime(row.traceApproveTime); signDisplay.changeDate = formatSignDate(row.changeDate || row.updateTime); } //update-end---author:cursor ---date:20260522 for:【XSLMES-20260522-A17】页脚签章区只读展示----------- @@ -1057,8 +1069,7 @@ async function onSpecPickerEdit(payload: Recordable | null) { return; } try { - const raw = await queryById({ id: payload.mixingSpecId }); - const row = (raw as Recordable)?.specName != null ? raw : (raw as Recordable)?.result; + const row = await loadRecordWithTrace(payload.mixingSpecId, MIXING_SPEC_BIZ_TABLE, queryById); if (!row?.id) { createMessage.warning('未找到混炼示方数据'); return; @@ -1159,17 +1170,25 @@ function ensureTcuDefaultRows(rows: Recordable[] = []) { } function handleTcuValueChange(event) { - //update-begin---author:cursor ---date:20260522 for:【XSLMES-20260522-A17】下密炼机禁用药品称量位置----------- const row = event?.row; const key = event?.column?.key; - if (!row || key !== 'drugWeighPos') { + if (!row || !key) { return; } - if (row.sectionType === 'down_mixer') { - row.drugWeighPos = undefined; - createMessage.warning('下密炼机不允许选择药品称量位置'); + //update-begin---author:cursor ---date:20260522 for:【XSLMES-20260522-A17】下密炼机禁用药品称量位置----------- + if (key === 'drugWeighPos') { + if (row.sectionType === 'down_mixer') { + row.drugWeighPos = undefined; + createMessage.warning('下密炼机不允许选择药品称量位置'); + } + return; } //update-end---author:cursor ---date:20260522 for:【XSLMES-20260522-A17】下密炼机禁用药品称量位置----------- + //update-begin---author:GHT ---date:2026-06-10 for:【混炼示方】TCU是否附加为否时清空重量----------- + if (key === 'isAttach' && !isMixingTcuAttachEnabled(row.isAttach)) { + row.attachWeight = undefined; + } + //update-end---author:GHT ---date:2026-06-10 for:【混炼示方】TCU是否附加为否时清空重量----------- } function resetSheetForm() { @@ -1231,8 +1250,7 @@ const [registerModal, { setModalProps, closeModal }] = useModalInner(async (data await setProps({ disabled: !showFooter.value }); setModalProps({ showOkBtn: showFooter.value, showCancelBtn: showFooter.value, confirmLoading: false }); if (isUpdate.value && data?.record?.id) { - const raw = await queryById({ id: data.record.id }); - const row = raw?.result || raw; + const row = await loadRecordWithTrace(data.record.id, MIXING_SPEC_BIZ_TABLE, queryById, data.record); await applyMixingSpecPageData(row, 'edit'); } else { const userInfo = userStore.getUserInfo || {}; @@ -1271,7 +1289,9 @@ async function handleSubmit() { downStepList: cleanRows(downStepList), tcuList: tcuList.map((row) => ({ ...row, + isAttach: row.isAttach ?? MIXING_TCU_ATTACH_NO, drugWeighPos: row.sectionType === 'down_mixer' ? undefined : row.drugWeighPos, + attachWeight: isMixingTcuAttachEnabled(row.isAttach) ? row.attachWeight : undefined, })), }; setModalProps({ confirmLoading: true }); From 617d47a3dbbca5525a090d4d53860d56a208ae17 Mon Sep 17 00:00:00 2001 From: geht <2947093423@qq.com> Date: Wed, 10 Jun 2026 16:33:44 +0800 Subject: [PATCH 6/7] =?UTF-8?q?MES=E6=9C=AC=E5=9C=B0=E5=AE=A1=E6=89=B9?= =?UTF-8?q?=E5=85=B1=E7=94=A8=E9=92=89=E9=92=89=E5=AE=A1=E6=89=B9=E7=AD=89?= =?UTF-8?q?=E9=85=8D=E7=BD=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../jeecg-module-xslmes/doc/代码修改日志 | 36 ++ .../MesXslApprovalHandleController.java | 16 + .../service/IMesXslApprovalHandleService.java | 8 + .../impl/MesXslApprovalHandleServiceImpl.java | 128 ++++++- .../DingTplBindFieldValueResolver.java | 33 +- .../service/DingTplImCardBuilder.java | 339 ++++++++++++++++++ .../service/IMesXslDingTplBindService.java | 3 + .../impl/MesXslDingTplBindServiceImpl.java | 42 +++ .../modules/im/service/ISysImChatService.java | 5 + .../im/service/impl/SysImChatServiceImpl.java | 209 ++++++++++- .../jeecg/modules/im/vo/SysImContactVO.java | 2 + .../V3.9.2_147__sys_im_work_notify_user.sql | 17 + .../views/approval/flow/approvalHandle.api.ts | 9 + .../system/im/ImBizRecordMessageContent.vue | 61 +++- jeecgboot-vue3/src/views/system/im/ImChat.vue | 64 +++- .../views/system/im/ImCreateGroupModal.vue | 10 +- .../src/views/system/im/imBizRecordMessage.ts | 6 + jeecgboot-vue3/src/views/system/im/imCache.ts | 4 + .../MesXslApprovalRecordList.vue | 24 +- 19 files changed, 980 insertions(+), 36 deletions(-) create mode 100644 jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/dingtalk/service/DingTplImCardBuilder.java create mode 100644 jeecg-boot/jeecg-module-system/jeecg-system-start/src/main/resources/flyway/sql/mysql/V3.9.2_147__sys_im_work_notify_user.sql diff --git a/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/doc/代码修改日志 b/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/doc/代码修改日志 index d921adb3..ce96a241 100644 --- a/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/doc/代码修改日志 +++ b/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/doc/代码修改日志 @@ -944,6 +944,42 @@ jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules 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: 【IM审批通用化】IM工作通知公众号(同事列表置顶+审批消息统一推送) ----- +jeecg-boot/jeecg-module-system/jeecg-system-start/src/main/resources/flyway/sql/mysql/V3.9.2_147__sys_im_work_notify_user.sql +jeecg-boot/jeecg-module-system/jeecg-system-biz/src/main/java/org/jeecg/modules/im/service/impl/SysImChatServiceImpl.java +jeecg-boot/jeecg-module-system/jeecg-system-biz/src/main/java/org/jeecg/modules/im/vo/SysImContactVO.java +jeecg-boot/jeecg-module-system/jeecg-system-biz/src/main/java/org/jeecg/modules/im/service/ISysImChatService.java +jeecgboot-vue3/src/views/system/im/ImChat.vue +jeecgboot-vue3/src/views/system/im/imCache.ts +jeecgboot-vue3/src/views/system/im/ImCreateGroupModal.vue + +-- author:GHT---date:20260610--for: 【IM审批通用化】审批待办IM发送修复(admin自审重复成员+独立事务防审批回滚) ----- +jeecg-boot/jeecg-module-system/jeecg-system-biz/src/main/java/org/jeecg/modules/im/service/impl/SysImChatServiceImpl.java + +-- author:GHT---date:20260610--for: 【IM审批通用化】IM卡片字段取值修复(下划线列名+valueMode与钉钉发起对齐) ----- +jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/dingtalk/service/DingTplImCardBuilder.java +jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/dingtalk/service/DingTplBindFieldValueResolver.java + +-- author:GHT---date:20260610--for: 【IM审批通用化】流转中实例支持补发IM审批卡片(审批台账入口) ----- +jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/service/IMesXslApprovalHandleService.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/approval/controller/MesXslApprovalHandleController.java +jeecgboot-vue3/src/views/approval/flow/approvalHandle.api.ts +jeecgboot-vue3/src/views/xslmes/approval/mesXslApprovalRecord/MesXslApprovalRecordList.vue + +-- author:GHT---date:20260610--for: 【IM审批通用化】发起人=处理人时审批待办改由admin代发IM消息 ----- +jeecg-boot/jeecg-module-system/jeecg-system-biz/src/main/java/org/jeecg/modules/im/service/ISysImChatService.java +jeecg-boot/jeecg-module-system/jeecg-system-biz/src/main/java/org/jeecg/modules/im/service/impl/SysImChatServiceImpl.java +jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/service/impl/MesXslApprovalHandleServiceImpl.java + +-- author:GHT---date:20260610--for: 【IM审批通用化】IM卡片复用钉钉模板字段+MES回调补齐stageKey走集成方案 ----- +jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/dingtalk/service/DingTplImCardBuilder.java +jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/dingtalk/service/IMesXslDingTplBindService.java +jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/dingtalk/service/impl/MesXslDingTplBindServiceImpl.java +jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/service/impl/MesXslApprovalHandleServiceImpl.java +jeecgboot-vue3/src/views/system/im/imBizRecordMessage.ts +jeecgboot-vue3/src/views/system/im/ImBizRecordMessageContent.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 diff --git a/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/controller/MesXslApprovalHandleController.java b/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/controller/MesXslApprovalHandleController.java index 6860c3e2..11e28e04 100644 --- a/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/controller/MesXslApprovalHandleController.java +++ b/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/controller/MesXslApprovalHandleController.java @@ -108,4 +108,20 @@ public class MesXslApprovalHandleController { return Result.OK(approvalHandleService.pendingList(user)); } //update-end---author:GHT ---date:2026-05-29 for:【QH-MES审批流完善】待办列表----- + + //update-begin---author:GHT ---date:20260610 for:【IM审批通用化】补发IM审批卡片(历史流转中实例)----------- + @Operation(summary = "审批办理-补发IM审批卡片(流转中)") + @PostMapping("/resendCard") + public Result resendCard(@RequestBody Map body) { + String instanceId = body.get("instanceId") == null ? null : String.valueOf(body.get("instanceId")); + String bizTable = body.get("bizTable") == null ? null : String.valueOf(body.get("bizTable")); + String bizDataId = body.get("bizDataId") == null ? null : String.valueOf(body.get("bizDataId")); + if (oConvertUtils.isEmpty(instanceId) + && (oConvertUtils.isEmpty(bizTable) || oConvertUtils.isEmpty(bizDataId))) { + return Result.error("请提供 instanceId 或 bizTable+bizDataId"); + } + LoginUser user = (LoginUser) SecurityUtils.getSubject().getPrincipal(); + return approvalHandleService.resendCard(instanceId, bizTable, bizDataId, user); + } + //update-end---author:GHT ---date:20260610 for:【IM审批通用化】补发IM审批卡片(历史流转中实例)----------- } diff --git a/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/service/IMesXslApprovalHandleService.java b/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/service/IMesXslApprovalHandleService.java index 4740346d..9b7abb50 100644 --- a/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/service/IMesXslApprovalHandleService.java +++ b/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/service/IMesXslApprovalHandleService.java @@ -57,6 +57,14 @@ public interface IMesXslApprovalHandleService { Result urge(String instanceId, LoginUser user); //update-end---author:GHT ---date:2026-05-29 for:【QH-MES审批流完善】催办接口----- + //update-begin---author:GHT ---date:20260610 for:【IM审批通用化】流转中实例补发IM审批卡片----------- + /** + * 补发当前节点审批卡片(用于历史数据未收到 IM 消息等场景)。 + * instanceId 与 (bizTable+bizDataId) 二选一;仅审批中且 MES 通道实例可补发。 + */ + Result resendCard(String instanceId, String bizTable, String bizDataId, LoginUser user); + //update-end---author:GHT ---date:20260610 for:【IM审批通用化】流转中实例补发IM审批卡片----------- + //update-begin---author:GHT ---date:2026-05-29 for:【QH-MES审批流完善】待办列表查询----- /** * 查询当前用户的待办审批列表(状态为审批中且当前处理人包含该用户)。 diff --git a/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/service/impl/MesXslApprovalHandleServiceImpl.java b/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/service/impl/MesXslApprovalHandleServiceImpl.java index 11b40107..7936d6fa 100644 --- a/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/service/impl/MesXslApprovalHandleServiceImpl.java +++ b/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/service/impl/MesXslApprovalHandleServiceImpl.java @@ -23,6 +23,7 @@ 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.jeecg.modules.xslmes.dingtalk.service.DingTplImCardBuilder; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.stereotype.Service; @@ -100,6 +101,11 @@ public class MesXslApprovalHandleServiceImpl implements IMesXslApprovalHandleSer @Autowired private IMesXslIntegrationPlanService integrationPlanService; //update-end---author:GHT ---date:20260605 for:【XSLMES-20260605-K8R2】驳回回退改由集成方案 onReject 驱动----------- + + //update-begin---author:GHT ---date:20260610 for:【IM审批通用化】IM卡片字段与钉钉模板绑定对齐----------- + @Autowired + private DingTplImCardBuilder dingTplImCardBuilder; + //update-end---author:GHT ---date:20260610 for:【IM审批通用化】IM卡片字段与钉钉模板绑定对齐----------- //update-end---author:GHT ---date:2026-05-29 for:【QH-MES审批流设计】审批与业务单据联动回调----- // ==================== 发起后进入首节点 ==================== @@ -691,11 +697,11 @@ public class MesXslApprovalHandleServiceImpl implements IMesXslApprovalHandleSer oConvertUtils.getString(inst.getApplyUserName(), inst.getApplyUser()), inst.getFlowName(), actionLabel); msgType = "text"; } - SysUser applicant = getUserSafely(inst.getApplyUser()); - String fromId = applicant == null ? null : applicant.getId(); + //update-begin---author:GHT ---date:20260610 for:【IM审批通用化】待办卡片由系统账号发给处理人,支持发起人=处理人----------- for (String uname : handlerUsernames) { - sendOne(fromId, uname, inst.getTenantId(), content, msgType); + sendApprovalHandlerNotify(uname, inst.getTenantId(), content, msgType); } + //update-end---author:GHT ---date:20260610 for:【IM审批通用化】待办卡片由系统账号发给处理人,支持发起人=处理人----------- } /** 抄送通知(无办理按钮) */ @@ -755,6 +761,24 @@ public class MesXslApprovalHandleServiceImpl implements IMesXslApprovalHandleSer } } + //update-begin---author:GHT ---date:20260610 for:【IM审批通用化】审批待办IM通知----------- + private void sendApprovalHandlerNotify(String toUsername, Integer tenantId, String content, String msgType) { + String uname = toUsername == null ? "" : toUsername.trim(); + if (oConvertUtils.isEmpty(uname)) { + return; + } + try { + SysUser to = sysUserService.getUserByName(uname); + if (to == null) { + return; + } + sysImChatService.sendApprovalHandlerMessage(to.getId(), tenantId, content, msgType); + } catch (Exception e) { + log.warn("发送审批待办IM消息失败 to={}", uname, e); + } + } + //update-end---author:GHT ---date:20260610 for:【IM审批通用化】审批待办IM通知----------- + /** 构建 biz_record 卡片 JSON(含 instanceId / actionLabel / canApprove,与前端 ImBizRecordPayload 对齐 v=2) */ private String buildCardJson(MesXslApprovalInstance inst, String actionLabel, boolean canApprove, String routePath) { //update-begin---author:GHT ---date:2026-05-29 for:【QH-MES审批流设计】区分审批卡片与抄送通知卡片----- @@ -766,6 +790,15 @@ public class MesXslApprovalHandleServiceImpl implements IMesXslApprovalHandleSer */ private String buildCardJson(MesXslApprovalInstance inst, String actionLabel, boolean canApprove, String routePath, boolean approvalCard) { //update-end---author:GHT ---date:2026-05-29 for:【QH-MES审批流设计】区分审批卡片与抄送通知卡片----- + //update-begin---author:GHT ---date:20260610 for:【IM审批通用化】优先按钉钉模板绑定构建卡片字段----------- + MesXslApprovalFlow flow = flowService.getById(inst.getFlowId()); + JSONObject dingPayload = dingTplImCardBuilder.buildCardPayload( + inst, flow, actionLabel, canApprove, routePath, approvalCard); + if (dingPayload != null) { + return dingPayload.toJSONString(); + } + //update-end---author:GHT ---date:20260610 for:【IM审批通用化】优先按钉钉模板绑定构建卡片字段----------- + JSONArray fields = new JSONArray(); addField(fields, "审批流", inst.getFlowName()); addField(fields, "单据", safeTitle(inst)); @@ -1353,10 +1386,26 @@ public class MesXslApprovalHandleServiceImpl implements IMesXslApprovalHandleSer if (user != null) { ctx.setOperatorUsername(user.getUsername()); ctx.setOperatorName(oConvertUtils.getString(user.getRealname(), user.getUsername())); + ctx.setOperatorTime(new Date()); } else { ctx.setOperatorUsername("system"); ctx.setOperatorName("系统"); } + //update-begin---author:GHT ---date:20260610 for:【IM审批通用化】MES通道补齐stageKey,与钉钉回调共用集成方案----------- + if (oConvertUtils.isNotEmpty(nodeId) && oConvertUtils.isNotEmpty(inst.getFlowId())) { + MesXslApprovalFlow flow = flowService.getById(inst.getFlowId()); + if (flow != null && oConvertUtils.isNotEmpty(flow.getFlowConfig())) { + JSONObject root = safeParse(flow.getFlowConfig()); + JSONObject node = root == null ? null : findNodeById(root, nodeId); + if (node != null) { + JSONObject props = node.getJSONObject("props"); + if (props != null && props.containsKey("stageKey")) { + ctx.setStageKey(props.getString("stageKey")); + } + } + } + } + //update-end---author:GHT ---date:20260610 for:【IM审批通用化】MES通道补齐stageKey,与钉钉回调共用集成方案----------- return ctx; } @@ -1455,7 +1504,7 @@ public class MesXslApprovalHandleServiceImpl implements IMesXslApprovalHandleSer if (oConvertUtils.isEmpty(uname)) { continue; } - sendOne(user.getId(), uname, inst.getTenantId(), + sendApprovalHandlerNotify(uname, inst.getTenantId(), "【催办提醒】" + applicantName + " 催促您处理「" + safeTitle(inst) + "」,请尽快审批。", "text"); } urgeTimeMap.put(instanceId, System.currentTimeMillis()); @@ -1463,6 +1512,77 @@ public class MesXslApprovalHandleServiceImpl implements IMesXslApprovalHandleSer } //update-end---author:GHT ---date:2026-05-29 for:【QH-MES审批流完善】催办:发起人向当前处理人发催办提醒----- + //update-begin---author:GHT ---date:20260610 for:【IM审批通用化】流转中实例补发IM审批卡片----------- + @Override + public Result resendCard(String instanceId, String bizTable, String bizDataId, LoginUser user) { + if (user == null) { + return Result.error("请先登录"); + } + MesXslApprovalInstance inst = resolveRunningInstance(instanceId, bizTable, bizDataId); + if (inst == null) { + return Result.error("未找到审批中的 MES 审批实例"); + } + if (!"0".equals(inst.getStatus())) { + return Result.error("该审批已结束,无法补发卡片"); + } + if (!canResendCard(user, inst)) { + return Result.error("仅发起人或当前处理人可以补发审批卡片"); + } + if (oConvertUtils.isEmpty(inst.getCurrentHandlers())) { + return Result.error("当前无待处理人,无法补发"); + } + MesXslApprovalFlow flow = flowService.getById(inst.getFlowId()); + if (flow == null) { + return Result.error("审批流不存在"); + } + List handlers = new ArrayList<>(); + for (String uname : inst.getCurrentHandlers().split(",")) { + if (oConvertUtils.isNotEmpty(uname)) { + handlers.add(uname.trim()); + } + } + if (handlers.isEmpty()) { + return Result.error("当前无待处理人,无法补发"); + } + String actionLabel = oConvertUtils.getString(inst.getCurrentNodeName(), "审批"); + sendApprovalCard(inst, flow, actionLabel, handlers); + return Result.OK("已向 " + handlers.size() + " 位处理人补发审批卡片,请在 IM 中查看与 admin 的会话"); + } + + private MesXslApprovalInstance resolveRunningInstance(String instanceId, String bizTable, String bizDataId) { + if (oConvertUtils.isNotEmpty(instanceId)) { + MesXslApprovalInstance inst = instanceService.getById(instanceId); + return inst == null || !"0".equals(inst.getStatus()) ? null : inst; + } + if (oConvertUtils.isEmpty(bizTable) || oConvertUtils.isEmpty(bizDataId) + || !IDENTIFIER.matcher(bizTable).matches()) { + return null; + } + return instanceService.lambdaQuery() + .eq(MesXslApprovalInstance::getBizTable, bizTable) + .eq(MesXslApprovalInstance::getBizDataId, bizDataId) + .eq(MesXslApprovalInstance::getStatus, "0") + .orderByDesc(MesXslApprovalInstance::getCreateTime) + .last("LIMIT 1") + .one(); + } + + private boolean canResendCard(LoginUser user, MesXslApprovalInstance inst) { + if (user.getUsername().equals(inst.getApplyUser())) { + return true; + } + if (oConvertUtils.isEmpty(inst.getCurrentHandlers())) { + return false; + } + for (String uname : inst.getCurrentHandlers().split(",")) { + if (user.getUsername().equals(uname == null ? "" : uname.trim())) { + return true; + } + } + return false; + } + //update-end---author:GHT ---date:20260610 for:【IM审批通用化】流转中实例补发IM审批卡片----------- + // ==================== 待办列表 ==================== //update-begin---author:GHT ---date:2026-05-29 for:【QH-MES审批流完善】待办列表:查询当前用户的待处理审批实例----- diff --git a/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/dingtalk/service/DingTplBindFieldValueResolver.java b/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/dingtalk/service/DingTplBindFieldValueResolver.java index b691de86..4ef41991 100644 --- a/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/dingtalk/service/DingTplBindFieldValueResolver.java +++ b/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/dingtalk/service/DingTplBindFieldValueResolver.java @@ -3,6 +3,7 @@ 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.common.util.oConvertUtils; import org.jeecg.modules.print.vo.PrintBizFieldItemVO; import org.jeecg.modules.system.service.ISysDictService; import org.springframework.beans.factory.annotation.Autowired; @@ -79,7 +80,7 @@ public class DingTplBindFieldValueResolver { return null; } if (cur instanceof Map) { - cur = ((Map) cur).get(p); + cur = getMapValue((Map) cur, p); } else { return null; } @@ -87,6 +88,32 @@ public class DingTplBindFieldValueResolver { return cur; } + /** JDBC 行多为下划线列名,绑定配置为驼峰字段名,双向兼容取值 */ + @SuppressWarnings("unchecked") + private Object getMapValue(Map map, String key) { + if (map == null || StringUtils.isBlank(key)) { + return null; + } + Object val = map.get(key); + if (val != null) { + return val; + } + String underline = oConvertUtils.camelToUnderline(key); + if (!underline.equals(key)) { + val = map.get(underline); + if (val != null) { + return val; + } + } + if (key.contains("_")) { + String camel = oConvertUtils.camelName(key); + if (!camel.equals(key)) { + val = map.get(camel); + } + } + return val; + } + @SuppressWarnings("unchecked") private String getDictTextFromRow(Object rowData, String bizField) { if (!(rowData instanceof Map) || StringUtils.isBlank(bizField)) { @@ -95,13 +122,13 @@ public class DingTplBindFieldValueResolver { Map map = (Map) rowData; String[] parts = bizField.split("\\."); if (parts.length == 1) { - Object v = map.get(parts[0] + "_dictText"); + Object v = getMapValue(map, 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"); + Object v = getMapValue((Map) parent, parts[parts.length - 1] + "_dictText"); return v != null ? String.valueOf(v) : null; } return null; diff --git a/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/dingtalk/service/DingTplImCardBuilder.java b/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/dingtalk/service/DingTplImCardBuilder.java new file mode 100644 index 00000000..ae351d0d --- /dev/null +++ b/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/dingtalk/service/DingTplImCardBuilder.java @@ -0,0 +1,339 @@ +package org.jeecg.modules.xslmes.dingtalk.service; + +import com.alibaba.fastjson2.JSON; +import com.alibaba.fastjson2.JSONArray; +import com.alibaba.fastjson2.JSONObject; +import java.text.SimpleDateFormat; +import java.util.Collections; +import java.util.Date; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.regex.Pattern; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang3.StringUtils; +import org.jeecg.common.util.oConvertUtils; +import org.jeecg.modules.print.catalog.IPrintBizEntityFieldCatalogProvider; +import org.jeecg.modules.print.service.IPrintBizPermEntityService; +import org.jeecg.modules.print.util.PrintBizDetailPropertyScanner; +import org.jeecg.modules.print.util.PrintBizEntityFieldIntrospector; +import org.jeecg.modules.print.vo.PrintBizDetailSlotVO; +import org.jeecg.modules.print.vo.PrintBizFieldItemVO; +import org.jeecg.modules.print.vo.PrintBizTypeVO; +import org.jeecg.modules.xslmes.approval.entity.MesXslApprovalFlow; +import org.jeecg.modules.xslmes.approval.entity.MesXslApprovalInstance; +import org.jeecg.modules.xslmes.dingtalk.entity.MesXslDingProcessTpl; +import org.jeecg.modules.xslmes.dingtalk.entity.MesXslDingTplBind; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Service; + +/** + * 按钉钉审批模板绑定配置构建 IM 聊天审批卡片字段,与钉钉发起审批表单展示对齐。 + */ +@Slf4j +@Service +public class DingTplImCardBuilder { + + private static final Pattern IDENTIFIER = Pattern.compile("^[A-Za-z0-9_]+$"); + private static final String CARD_STYLE_DING = "ding"; + /** 与前端 dingTplFieldValue.defaultValueMode 一致:下拉类控件默认原值 */ + private static final Set DD_SELECT_COMPONENTS = Set.of( + "DDSelectField", "DDMultiSelectField", "DepartmentField", "InnerContactField"); + + @Autowired + private IMesXslDingTplBindService bindService; + @Autowired + private IMesXslDingProcessTplService tplService; + @Autowired + private DingTplBindFieldValueResolver fieldValueResolver; + @Autowired + private IPrintBizPermEntityService printBizPermEntityService; + @Autowired + private JdbcTemplate jdbcTemplate; + + @Autowired(required = false) + private IPrintBizEntityFieldCatalogProvider fieldCatalogProvider; + + /** + * 若存在启用的钉钉模板绑定,则按绑定字段构建完整 biz_record 载荷;否则返回 null 由调用方降级。 + */ + public JSONObject buildCardPayload(MesXslApprovalInstance inst, MesXslApprovalFlow flow, + String actionLabel, boolean canApprove, String routePath, + boolean approvalCard) { + if (inst == null || oConvertUtils.isEmpty(routePath)) { + return null; + } + MesXslDingTplBind bind = bindService.resolveActiveByRoutePath(routePath); + if (bind == null || oConvertUtils.isEmpty(bind.getFieldMappingJson())) { + return null; + } + MesXslDingProcessTpl tpl = tplService.getById(bind.getTemplateId()); + if (tpl == null || !"1".equals(tpl.getStatus())) { + return null; + } + + Map rowData = loadBizRow(inst.getBizTable(), inst.getBizDataId()); + if (rowData.isEmpty()) { + return null; + } + + Map metaMap = buildFieldMetaMap(bind.getBizCode()); + JSONArray fields = buildDingStyleFields(bind.getFieldMappingJson(), rowData, metaMap); + appendApprovalMetaFields(fields, inst, flow, approvalCard); + + JSONObject item = buildItem(inst, routePath, fields, actionLabel, canApprove, approvalCard); + JSONArray items = new JSONArray(); + items.add(item); + + JSONObject payload = new JSONObject(); + payload.put("v", 2); + payload.put("cardStyle", CARD_STYLE_DING); + payload.put("templateId", bind.getTemplateId()); + payload.put("templateName", oConvertUtils.getString(bind.getTemplateName(), tpl.getTplName())); + payload.put("pageTitle", oConvertUtils.getString(inst.getBizTableName(), flow != null ? flow.getFlowName() : inst.getFlowName())); + payload.put("pagePath", routePath); + payload.put("rowKey", "id"); + payload.put("items", items); + return payload; + } + + private JSONArray buildDingStyleFields(String mappingJson, Map rowData, + Map metaMap) { + JSONArray fields = new JSONArray(); + JSONArray mappings; + try { + mappings = JSON.parseArray(mappingJson); + } catch (Exception e) { + log.warn("解析钉钉模板绑定 JSON 失败: {}", e.getMessage()); + return fields; + } + if (mappings == null || mappings.isEmpty()) { + return fields; + } + for (int i = 0; i < mappings.size(); i++) { + JSONObject mapping = mappings.getJSONObject(i); + if (mapping == null) { + continue; + } + String componentName = mapping.getString("componentName"); + if ("TableField".equals(componentName)) { + continue; + } + String label = mapping.getString("componentLabel"); + String bizField = mapping.getString("bizField"); + if (oConvertUtils.isEmpty(label) || oConvertUtils.isEmpty(bizField)) { + if ("TextNote".equals(componentName) && oConvertUtils.isNotEmpty(label)) { + addField(fields, label, ""); + } + continue; + } + PrintBizFieldItemVO meta = metaMap.get(bizField.trim()); + String valueMode = resolveValueMode(mapping, meta); + Object val = fieldValueResolver.resolveValue(rowData, bizField.trim(), valueMode, meta); + addField(fields, label, formatValue(val)); + } + return fields; + } + + private void appendApprovalMetaFields(JSONArray fields, MesXslApprovalInstance inst, + MesXslApprovalFlow flow, boolean approvalCard) { + addField(fields, "审批流", oConvertUtils.getString(inst.getFlowName(), + flow != null ? flow.getFlowName() : "")); + addField(fields, approvalCard ? "当前节点" : "知会", + oConvertUtils.getString(inst.getCurrentNodeName(), approvalCard ? "审批" : "抄送")); + addField(fields, "状态", statusText(inst.getStatus())); + } + + private JSONObject buildItem(MesXslApprovalInstance inst, String routePath, JSONArray fields, + String actionLabel, boolean canApprove, boolean approvalCard) { + JSONObject item = new JSONObject(); + item.put("recordId", inst.getBizDataId()); + item.put("fields", fields); + StringBuilder body = new StringBuilder(); + for (int i = 0; i < fields.size(); i++) { + JSONObject f = fields.getJSONObject(i); + if (i > 0) { + body.append("\n"); + } + body.append(f.getString("label")).append(": ").append(f.getString("value")); + } + item.put("body", body.toString()); + String sep = routePath.contains("?") ? "&" : "?"; + item.put("linkPath", routePath + sep + "imRecordId=" + inst.getBizDataId()); + if (approvalCard) { + item.put("instanceId", inst.getId()); + item.put("canApprove", canApprove); + item.put("nodeId", inst.getCurrentNodeId()); + if (oConvertUtils.isNotEmpty(actionLabel)) { + item.put("actionLabel", actionLabel); + } + } + return item; + } + + private Map loadBizRow(String table, String bizDataId) { + if (oConvertUtils.isEmpty(table) || oConvertUtils.isEmpty(bizDataId) + || !IDENTIFIER.matcher(table).matches()) { + return Collections.emptyMap(); + } + try { + List> rows = jdbcTemplate.queryForList( + "SELECT * FROM `" + table + "` WHERE id = ? LIMIT 1", bizDataId); + if (rows.isEmpty()) { + return Collections.emptyMap(); + } + //update-begin---author:GHT ---date:20260610 for:【IM审批通用化】JDBC列名下划线转驼峰,与绑定字段对齐----------- + return normalizeRowKeys(rows.get(0)); + //update-end---author:GHT ---date:20260610 for:【IM审批通用化】JDBC列名下划线转驼峰,与绑定字段对齐----------- + } catch (Exception e) { + log.warn("加载业务单据失败 table={} id={}", table, bizDataId, e); + return Collections.emptyMap(); + } + } + + private Map normalizeRowKeys(Map raw) { + Map normalized = new LinkedHashMap<>(); + if (raw == null) { + return normalized; + } + for (Map.Entry e : raw.entrySet()) { + String key = e.getKey(); + if (oConvertUtils.isEmpty(key)) { + continue; + } + normalized.put(key, e.getValue()); + if (key.contains("_")) { + String camel = oConvertUtils.camelName(key); + normalized.putIfAbsent(camel, e.getValue()); + } + } + return normalized; + } + + /** 与钉钉发起弹窗 defaultValueMode 对齐 */ + private String resolveValueMode(JSONObject mapping, PrintBizFieldItemVO meta) { + String mode = mapping.getString("valueMode"); + if (oConvertUtils.isNotEmpty(mode)) { + return mode.trim(); + } + if (meta == null || oConvertUtils.isEmpty(meta.getTranslateKind()) + || "NONE".equalsIgnoreCase(meta.getTranslateKind())) { + return "raw"; + } + String componentName = mapping.getString("componentName"); + if (oConvertUtils.isNotEmpty(componentName) && DD_SELECT_COMPONENTS.contains(componentName)) { + return "raw"; + } + return "text"; + } + + private Map buildFieldMetaMap(String bizCode) { + if (oConvertUtils.isEmpty(bizCode)) { + return Collections.emptyMap(); + } + Map 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 List listMainFieldsEnriched(String bizCode) { + Class cls = resolveEntityClass(bizCode); + List 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 void mergeDetailMeta(Map map, String bizCode, String prop, String kind) { + List 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, null); + } + if (detailFields != null) { + for (PrintBizFieldItemVO f : detailFields) { + if (f != null && StringUtils.isNotBlank(f.getFieldKey())) { + map.put(f.getFieldKey(), f); + } + } + } + } + + 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 Class resolveDetailItemClass(String bizCode, String detailProperty, String slotKind) { + Class mainCls = resolveEntityClass(bizCode); + if (mainCls == null) { + return null; + } + return PrintBizDetailPropertyScanner.resolveItemClassForSlot(mainCls, detailProperty, slotKind); + } + + private void addField(JSONArray fields, String label, String value) { + JSONObject f = new JSONObject(); + f.put("label", label); + f.put("value", oConvertUtils.getString(value, "")); + fields.add(f); + } + + private String formatValue(Object val) { + if (val == null) { + return ""; + } + if (val instanceof Date) { + return new SimpleDateFormat("yyyy-MM-dd").format((Date) val); + } + return String.valueOf(val); + } + + private String statusText(String status) { + if ("1".equals(status)) { + return "已通过"; + } + if ("2".equals(status)) { + return "已驳回"; + } + if ("3".equals(status)) { + return "已撤销"; + } + return "审批中"; + } +} diff --git a/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/dingtalk/service/IMesXslDingTplBindService.java b/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/dingtalk/service/IMesXslDingTplBindService.java index 9b18bfd5..2371f119 100644 --- a/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/dingtalk/service/IMesXslDingTplBindService.java +++ b/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/dingtalk/service/IMesXslDingTplBindService.java @@ -13,4 +13,7 @@ public interface IMesXslDingTplBindService extends IService { /** 按业务编码查询绑定记录(未删除的第一条) */ MesXslDingTplBind getByBizCode(String bizCode); + + /** 按前端路由解析启用的钉钉模板绑定(模板停用则返回 null) */ + MesXslDingTplBind resolveActiveByRoutePath(String routePath); } diff --git a/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/dingtalk/service/impl/MesXslDingTplBindServiceImpl.java b/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/dingtalk/service/impl/MesXslDingTplBindServiceImpl.java index e539b3a1..fa58d5bb 100644 --- a/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/dingtalk/service/impl/MesXslDingTplBindServiceImpl.java +++ b/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/dingtalk/service/impl/MesXslDingTplBindServiceImpl.java @@ -2,11 +2,18 @@ package org.jeecg.modules.xslmes.dingtalk.service.impl; import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper; import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; +import org.apache.commons.lang3.StringUtils; +import org.jeecg.modules.xslmes.dingtalk.entity.MesXslDingProcessTpl; import org.jeecg.modules.xslmes.dingtalk.entity.MesXslDingTplBind; import org.jeecg.modules.xslmes.dingtalk.mapper.MesXslDingTplBindMapper; +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.stereotype.Service; +import java.util.List; + /** * 钉钉审批模板绑定 ServiceImpl * @@ -17,8 +24,43 @@ import org.springframework.stereotype.Service; public class MesXslDingTplBindServiceImpl extends ServiceImpl implements IMesXslDingTplBindService { + @Autowired + private JdbcTemplate jdbcTemplate; + + @Autowired + private IMesXslDingProcessTplService tplService; + @Override public MesXslDingTplBind getByBizCode(String bizCode) { return getOne(new QueryWrapper().eq("biz_code", bizCode).last("LIMIT 1")); } + + //update-begin---author:GHT ---date:20260610 for:【IM审批通用化】按路由解析启用的钉钉模板绑定----------- + @Override + public MesXslDingTplBind resolveActiveByRoutePath(String routePath) { + if (StringUtils.isBlank(routePath)) { + return null; + } + String path = routePath.trim(); + String sql = "SELECT id FROM sys_permission WHERE url = ? AND del_flag = 0 LIMIT 1"; + List ids; + try { + ids = jdbcTemplate.queryForList(sql, String.class, path); + } catch (Exception e) { + return null; + } + if (ids.isEmpty()) { + return null; + } + MesXslDingTplBind bind = getByBizCode(ids.get(0)); + if (bind == null || StringUtils.isBlank(bind.getTemplateId())) { + return null; + } + MesXslDingProcessTpl tpl = tplService.getById(bind.getTemplateId()); + if (tpl == null || !"1".equals(tpl.getStatus())) { + return null; + } + return bind; + } + //update-end---author:GHT ---date:20260610 for:【IM审批通用化】按路由解析启用的钉钉模板绑定----------- } diff --git a/jeecg-boot/jeecg-module-system/jeecg-system-biz/src/main/java/org/jeecg/modules/im/service/ISysImChatService.java b/jeecg-boot/jeecg-module-system/jeecg-system-biz/src/main/java/org/jeecg/modules/im/service/ISysImChatService.java index 621d174c..d73957cf 100644 --- a/jeecg-boot/jeecg-module-system/jeecg-system-biz/src/main/java/org/jeecg/modules/im/service/ISysImChatService.java +++ b/jeecg-boot/jeecg-module-system/jeecg-system-biz/src/main/java/org/jeecg/modules/im/service/ISysImChatService.java @@ -61,6 +61,11 @@ public interface ISysImChatService { * @return 消息VO,发送失败返回 null */ SysImMessageVO sendSystemSingleMessage(String fromUserId, String toUserId, Integer tenantId, String content, String msgType); + + /** + * 审批待办卡片:从「工作通知」公众号推送给处理人。 + */ + SysImMessageVO sendApprovalHandlerMessage(String toUserId, Integer tenantId, String content, String msgType); //update-end---author:GHT ---date:2026-05-29 for:【QH-MES审批流设计】系统单聊消息(绕过同部门校验,供审批等系统通知场景)----- diff --git a/jeecg-boot/jeecg-module-system/jeecg-system-biz/src/main/java/org/jeecg/modules/im/service/impl/SysImChatServiceImpl.java b/jeecg-boot/jeecg-module-system/jeecg-system-biz/src/main/java/org/jeecg/modules/im/service/impl/SysImChatServiceImpl.java index d715f1fd..68a3cbc9 100644 --- a/jeecg-boot/jeecg-module-system/jeecg-system-biz/src/main/java/org/jeecg/modules/im/service/impl/SysImChatServiceImpl.java +++ b/jeecg-boot/jeecg-module-system/jeecg-system-biz/src/main/java/org/jeecg/modules/im/service/impl/SysImChatServiceImpl.java @@ -36,6 +36,7 @@ import org.jeecg.modules.system.mapper.SysUserTenantMapper; import org.jeecg.modules.system.service.ISysPermissionService; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Propagation; import org.springframework.transaction.annotation.Transactional; import java.util.ArrayList; @@ -64,6 +65,13 @@ public class SysImChatServiceImpl implements ISysImChatService { private static final String MSG_IMAGE_PREVIEW = "[图片]"; private static final String MSG_BIZ_RECORD_PREVIEW = "[业务数据]"; private static final String IM_RECORD_QUERY_KEY = "imRecordId"; + /** IM 工作通知公众号账号(审批等系统消息统一从此账号推送) */ + private static final String WORK_NOTIFY_USERNAME = "im_work_notify"; + private static final String WORK_NOTIFY_REALNAME = "工作通知"; + private static final String WORK_NOTIFY_USER_ID = "1995000000000000999"; + private static final String CONTACT_TYPE_WORK_NOTIFY = "work_notify"; + private static final String CONTACT_TYPE_USER = "user"; + private volatile String workNotifyUserIdCache; @Autowired private ISysPermissionService sysPermissionService; @@ -107,7 +115,11 @@ public class SysImChatServiceImpl implements ISysImChatService { if (conversation != null) { return buildConversationVo(conversation, userId, targetUserId); } - validateTenantChat(userId, tenantId, orgCode, targetUserId); + //update-begin---author:GHT ---date:20260610 for:【IM审批通用化】工作通知公众号会话免同部门校验----------- + if (!isWorkNotifyUser(targetUserId)) { + validateTenantChat(userId, tenantId, orgCode, targetUserId); + } + //update-end---author:GHT ---date:20260610 for:【IM审批通用化】工作通知公众号会话免同部门校验----------- //update-end---author:cursor ---date:20260528 for:【IM聊天-OA】已有会话快速打开----------- conversation = new SysImConversation(); conversation.setConvType(CONV_TYPE_SINGLE); @@ -439,6 +451,11 @@ public class SysImChatServiceImpl implements ISysImChatService { throw new JeecgBootException("消息内容不能为空"); } assertMember(userId, dto.getConversationId()); + //update-begin---author:GHT ---date:20260610 for:【IM审批通用化】工作通知公众号只读,禁止用户发消息----------- + if (isWorkNotifyConversation(dto.getConversationId())) { + throw new JeecgBootException("工作通知为系统消息通道,不支持发送消息"); + } + //update-end---author:GHT ---date:20260610 for:【IM审批通用化】工作通知公众号只读,禁止用户发消息----------- Date now = new Date(); SysImMessage message = new SysImMessage(); message.setConversationId(dto.getConversationId()); @@ -480,8 +497,8 @@ public class SysImChatServiceImpl implements ISysImChatService { if (oConvertUtils.isEmpty(fromUserId) || oConvertUtils.isEmpty(toUserId) || oConvertUtils.isEmpty(content)) { return null; } - // 不给自己发送 if (fromUserId.equals(toUserId)) { + log.warn("IM系统消息跳过:收发为同一人 toUserId={}", toUserId); return null; } Integer tenant = tenantId == null ? 0 : tenantId; @@ -500,8 +517,17 @@ public class SysImChatServiceImpl implements ISysImChatService { conversation.setCreateTime(now); conversation.setUpdateTime(now); conversationMapper.insert(conversation); - createMember(conversation.getId(), fromUserId, now); - createMember(conversation.getId(), toUserId, now); + ensureMember(conversation.getId(), fromUserId, now); + if (!fromUserId.equals(toUserId)) { + ensureMember(conversation.getId(), toUserId, now); + } + } else { + //update-begin---author:GHT ---date:20260610 for:【IM审批通用化】已有会话补全缺失成员----------- + ensureMember(conversation.getId(), fromUserId, now); + if (!fromUserId.equals(toUserId)) { + ensureMember(conversation.getId(), toUserId, now); + } + //update-end---author:GHT ---date:20260610 for:【IM审批通用化】已有会话补全缺失成员----------- } // 写入消息 SysImMessage message = new SysImMessage(); @@ -522,6 +548,22 @@ public class SysImChatServiceImpl implements ISysImChatService { pushChatMessage(conversation.getId(), fromUserId, messageVo, conversation.getConvType()); return messageVo; } + + //update-begin---author:GHT ---date:20260610 for:【IM审批通用化】审批待办统一由系统账号发给处理人----------- + @Override + @Transactional(propagation = Propagation.REQUIRES_NEW, rollbackFor = Exception.class) + public SysImMessageVO sendApprovalHandlerMessage(String toUserId, Integer tenantId, String content, String msgType) { + if (oConvertUtils.isEmpty(toUserId) || oConvertUtils.isEmpty(content)) { + return null; + } + String fromUserId = ensureWorkNotifyUserId(); + if (fromUserId.equals(toUserId)) { + log.warn("审批待办IM发送失败:接收人不能是工作通知公众号 toUserId={}", toUserId); + return null; + } + return sendSystemSingleMessage(fromUserId, toUserId, tenantId, content, msgType); + } + //update-end---author:GHT ---date:20260610 for:【IM审批通用化】审批待办统一由系统账号发给处理人----------- //update-end---author:GHT ---date:2026-05-29 for:【QH-MES审批流设计】系统单聊消息(绕过同部门校验)----- //update-begin---author:cursor ---date:20260528 for:【IM聊天-OA】标记已读----------- @@ -541,13 +583,19 @@ public class SysImChatServiceImpl implements ISysImChatService { //update-begin---author:cursor ---date:20260528 for:【IM聊天-OA】本部门成员列表(含会话摘要)----------- @Override public List listDeptMembers(String userId, Integer tenantId, String orgCode, String keyword) { + List result = new ArrayList<>(); + //update-begin---author:GHT ---date:20260610 for:【IM审批通用化】同事列表置顶工作通知公众号----------- + if (matchWorkNotifyKeyword(keyword)) { + result.add(buildWorkNotifyContact(userId, tenantId)); + } + String workNotifyId = ensureWorkNotifyUserId(); String resolvedOrgCode = resolveOrgCode(userId, tenantId, orgCode); if (oConvertUtils.isEmpty(resolvedOrgCode)) { - throw new JeecgBootException("未获取到当前部门,请切换部门后重试"); + return result; } List users = userDepartMapper.querySameDepartUserList(resolvedOrgCode, userId, tenantId, keyword); if (users == null || users.isEmpty()) { - return Collections.emptyList(); + return result; } Map convMap = new HashMap<>(16); if (tenantId != null && tenantId > 0) { @@ -557,20 +605,26 @@ public class SysImChatServiceImpl implements ISysImChatService { } } } - List result = users.stream().map(user -> { - SysImContactVO vo = toContactVo(user); - SysImConversationVO conv = convMap.get(user.getId()); - if (conv != null) { - vo.setConversationId(conv.getConversationId()); - vo.setLastContent(conv.getLastContent()); - vo.setLastTime(conv.getLastTime()); - vo.setUnreadCount(conv.getUnreadCount()); - } - return vo; - }).collect(Collectors.toList()); - result.sort(Comparator + List colleagues = users.stream() + .filter(user -> !workNotifyId.equals(user.getId()) && !WORK_NOTIFY_USERNAME.equals(user.getUsername())) + .map(user -> { + SysImContactVO vo = toContactVo(user); + vo.setContactType(CONTACT_TYPE_USER); + SysImConversationVO conv = convMap.get(user.getId()); + if (conv != null) { + vo.setConversationId(conv.getConversationId()); + vo.setLastContent(conv.getLastContent()); + vo.setLastTime(conv.getLastTime()); + vo.setUnreadCount(conv.getUnreadCount()); + } + return vo; + }) + .collect(Collectors.toList()); + colleagues.sort(Comparator .comparing(SysImContactVO::getLastTime, Comparator.nullsLast(Comparator.reverseOrder())) .thenComparing(item -> oConvertUtils.getString(item.getRealname(), item.getUsername()))); + result.addAll(colleagues); + //update-end---author:GHT ---date:20260610 for:【IM审批通用化】同事列表置顶工作通知公众号----------- return result; } //update-end---author:cursor ---date:20260528 for:【IM聊天-OA】本部门成员列表(含会话摘要)----------- @@ -583,9 +637,111 @@ public class SysImChatServiceImpl implements ISysImChatService { vo.setAvatar(user.getAvatar()); vo.setOrgCodeTxt(user.getOrgCodeTxt()); vo.setUnreadCount(0); + vo.setContactType(CONTACT_TYPE_USER); return vo; } + private SysImContactVO buildWorkNotifyContact(String userId, Integer tenantId) { + String workNotifyId = ensureWorkNotifyUserId(); + SysImContactVO vo = new SysImContactVO(); + vo.setId(workNotifyId); + vo.setUsername(WORK_NOTIFY_USERNAME); + vo.setRealname(WORK_NOTIFY_REALNAME); + vo.setUnreadCount(0); + vo.setContactType(CONTACT_TYPE_WORK_NOTIFY); + if (tenantId != null && tenantId > 0) { + SysImConversationVO conv = findSingleConversationSummary(userId, tenantId, workNotifyId); + if (conv != null) { + vo.setConversationId(conv.getConversationId()); + vo.setLastContent(conv.getLastContent()); + vo.setLastTime(conv.getLastTime()); + vo.setUnreadCount(conv.getUnreadCount()); + } + } + return vo; + } + + private SysImConversationVO findSingleConversationSummary(String userId, Integer tenantId, String targetUserId) { + String pairKey = buildPairKey(userId, targetUserId); + SysImConversation conversation = conversationMapper.selectOne(new LambdaQueryWrapper() + .eq(SysImConversation::getTenantId, tenantId) + .eq(SysImConversation::getUserPairKey, pairKey)); + if (conversation == null) { + return null; + } + SysImConversationMember member = getMember(userId, conversation.getId()); + SysImConversationVO vo = buildConversationVo(conversation, userId, targetUserId); + vo.setUnreadCount(member == null ? 0 : member.getUnreadCount()); + return vo; + } + + private boolean matchWorkNotifyKeyword(String keyword) { + if (oConvertUtils.isEmpty(keyword)) { + return true; + } + String key = keyword.trim().toLowerCase(); + return WORK_NOTIFY_REALNAME.contains(keyword.trim()) + || WORK_NOTIFY_REALNAME.toLowerCase().contains(key) + || WORK_NOTIFY_USERNAME.contains(key) + || key.contains("工作") + || key.contains("通知"); + } + + private String ensureWorkNotifyUserId() { + if (oConvertUtils.isNotEmpty(workNotifyUserIdCache)) { + return workNotifyUserIdCache; + } + synchronized (this) { + if (oConvertUtils.isNotEmpty(workNotifyUserIdCache)) { + return workNotifyUserIdCache; + } + SysUser user = userMapper.selectOne(new LambdaQueryWrapper() + .eq(SysUser::getUsername, WORK_NOTIFY_USERNAME) + .eq(SysUser::getDelFlag, 0) + .last("LIMIT 1")); + if (user == null) { + user = userMapper.selectById(WORK_NOTIFY_USER_ID); + } + if (user == null) { + user = createWorkNotifyUser(); + } + workNotifyUserIdCache = user.getId(); + return workNotifyUserIdCache; + } + } + + private SysUser createWorkNotifyUser() { + SysUser user = new SysUser(); + user.setId(WORK_NOTIFY_USER_ID); + user.setUsername(WORK_NOTIFY_USERNAME); + user.setRealname(WORK_NOTIFY_REALNAME); + user.setPassword("disabled"); + user.setSalt(""); + user.setStatus(2); + user.setDelFlag(0); + user.setCreateBy("system"); + user.setCreateTime(new Date()); + userMapper.insert(user); + return user; + } + + private boolean isWorkNotifyUser(String userId) { + return oConvertUtils.isNotEmpty(userId) && userId.equals(ensureWorkNotifyUserId()); + } + + private boolean isWorkNotifyConversation(String conversationId) { + if (oConvertUtils.isEmpty(conversationId)) { + return false; + } + SysImConversation conversation = conversationMapper.selectById(conversationId); + if (conversation == null || !CONV_TYPE_SINGLE.equals(conversation.getConvType())) { + return false; + } + String workNotifyId = ensureWorkNotifyUserId(); + String pairKey = conversation.getUserPairKey(); + return oConvertUtils.isNotEmpty(pairKey) && pairKey.contains(workNotifyId); + } + private String resolveOrgCode(String userId, Integer tenantId, String orgCode) { if (oConvertUtils.isNotEmpty(orgCode)) { return orgCode; @@ -610,6 +766,17 @@ public class SysImChatServiceImpl implements ISysImChatService { } private void createMember(String conversationId, String userId, Date now) { + ensureMember(conversationId, userId, now); + } + + /** 幂等创建会话成员,避免 uk_im_member 重复插入 */ + private void ensureMember(String conversationId, String userId, Date now) { + if (oConvertUtils.isEmpty(conversationId) || oConvertUtils.isEmpty(userId)) { + return; + } + if (getMember(userId, conversationId) != null) { + return; + } SysImConversationMember member = new SysImConversationMember(); member.setConversationId(conversationId); member.setUserId(userId); @@ -742,7 +909,11 @@ public class SysImChatServiceImpl implements ISysImChatService { sender = userMapper.selectById(message.getSenderId()); } if (sender != null) { - vo.setSenderName(sender.getRealname()); + if (WORK_NOTIFY_USERNAME.equals(sender.getUsername())) { + vo.setSenderName(WORK_NOTIFY_REALNAME); + } else { + vo.setSenderName(sender.getRealname()); + } vo.setSenderAvatar(sender.getAvatar()); } return vo; diff --git a/jeecg-boot/jeecg-module-system/jeecg-system-biz/src/main/java/org/jeecg/modules/im/vo/SysImContactVO.java b/jeecg-boot/jeecg-module-system/jeecg-system-biz/src/main/java/org/jeecg/modules/im/vo/SysImContactVO.java index e0020865..4aa39b8c 100644 --- a/jeecg-boot/jeecg-module-system/jeecg-system-biz/src/main/java/org/jeecg/modules/im/vo/SysImContactVO.java +++ b/jeecg-boot/jeecg-module-system/jeecg-system-biz/src/main/java/org/jeecg/modules/im/vo/SysImContactVO.java @@ -28,4 +28,6 @@ public class SysImContactVO { private java.util.Date lastTime; @Schema(description = "未读数") private Integer unreadCount; + @Schema(description = "联系人类型 user=同事 work_notify=工作通知公众号") + private String contactType; } diff --git a/jeecg-boot/jeecg-module-system/jeecg-system-start/src/main/resources/flyway/sql/mysql/V3.9.2_147__sys_im_work_notify_user.sql b/jeecg-boot/jeecg-module-system/jeecg-system-start/src/main/resources/flyway/sql/mysql/V3.9.2_147__sys_im_work_notify_user.sql new file mode 100644 index 00000000..fa70cbb8 --- /dev/null +++ b/jeecg-boot/jeecg-module-system/jeecg-system-start/src/main/resources/flyway/sql/mysql/V3.9.2_147__sys_im_work_notify_user.sql @@ -0,0 +1,17 @@ +-- IM 工作通知公众号系统账号(审批等系统消息统一从此账号推送) +SET NAMES utf8mb4; + +INSERT IGNORE INTO `sys_user` ( + `id`, `username`, `realname`, `password`, `salt`, `status`, `del_flag`, `activiti_sync`, `create_by`, `create_time` +) VALUES ( + '1995000000000000999', + 'im_work_notify', + '工作通知', + 'disabled', + '', + 2, + 0, + 1, + 'system', + NOW() +); diff --git a/jeecgboot-vue3/src/views/approval/flow/approvalHandle.api.ts b/jeecgboot-vue3/src/views/approval/flow/approvalHandle.api.ts index 346414a7..b77746a2 100644 --- a/jeecgboot-vue3/src/views/approval/flow/approvalHandle.api.ts +++ b/jeecgboot-vue3/src/views/approval/flow/approvalHandle.api.ts @@ -13,6 +13,9 @@ enum Api { // update-begin---author:GHT ---date:2026-05-29 for:【QH-MES审批流设计】发起人撤销----- cancel = '/xslmes/approvalHandle/cancel', // update-end---author:GHT ---date:2026-05-29 for:【QH-MES审批流设计】发起人撤销----- + // update-begin---author:GHT ---date:2026-06-10 for:【IM审批通用化】补发IM审批卡片----- + resendCard = '/xslmes/approvalHandle/resendCard', + // update-end---author:GHT ---date:2026-06-10 for:【IM审批通用化】补发IM审批卡片----- } /** 查看单据全部字段 + 审批进度/历史 */ @@ -31,3 +34,9 @@ export const rejectApproval = (params: { instanceId: string; reason: string }) = /** 撤销(仅发起人本人,审批中可撤回,业务单据恢复到发起时状态) */ export const cancelApproval = (params: { instanceId: string; reason?: string }) => defHttp.post({ url: Api.cancel, data: params }); // update-end---author:GHT ---date:2026-05-29 for:【QH-MES审批流设计】发起人撤销----- + +// update-begin---author:GHT ---date:2026-06-10 for:【IM审批通用化】补发IM审批卡片----- +/** 补发当前节点 IM 审批卡片(instanceId 与 bizTable+bizDataId 二选一) */ +export const resendApprovalCard = (params: { instanceId?: string; bizTable?: string; bizDataId?: string }) => + defHttp.post({ url: Api.resendCard, data: params }); +// update-end---author:GHT ---date:2026-06-10 for:【IM审批通用化】补发IM审批卡片----- diff --git a/jeecgboot-vue3/src/views/system/im/ImBizRecordMessageContent.vue b/jeecgboot-vue3/src/views/system/im/ImBizRecordMessageContent.vue index cc97a7fd..98df7207 100644 --- a/jeecgboot-vue3/src/views/system/im/ImBizRecordMessageContent.vue +++ b/jeecgboot-vue3/src/views/system/im/ImBizRecordMessageContent.vue @@ -5,7 +5,13 @@