diff --git a/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/doc/代码修改日志 b/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/doc/代码修改日志 index 03aaee7..e57fbdc 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 dba2379..f79e295 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 c282a5c..6e77945 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 185cf5a..34d10f1 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 5900f0b..4f745ca 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 c31fb96..b4198fc 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 afe1d9b..4cb8565 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 0000000..48ffb94 --- /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 0000000..c9da34c --- /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 bdb092f..56faa15 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 b94154c..5393baf 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 fdcfc5e..f7ce248 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 29859b0..e43ab15 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 f2ba820..0aefb04 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 e2f1068..9cd17c4 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 94bdf02..83bcffb 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 2ad0de8..a4dc506 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 7f1b373..265f392 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 0000000..963a11d --- /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 0000000..bc6c8ff --- /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 0000000..cad25b4 --- /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 0000000..120327b --- /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 2bc6dcb..36c5952 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 0000000..8430fee --- /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 cd63fcb..87e41cc 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 34023a9..b7cf943 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 25c9d2a..e933b45 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 bc1e167..ab5ddd3 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 29ceba0..ee3c054 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 0000000..5cab065 --- /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 0000000..01b264f --- /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 0000000..954b613 --- /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 d04d655..c0106a2 100644 --- a/jeecgboot-vue3/src/views/system/user/index.vue +++ b/jeecgboot-vue3/src/views/system/user/index.vue @@ -9,6 +9,9 @@ 回收站 + + 同步钉钉ID +