钉钉审批配置优化

This commit is contained in:
geht
2026-06-08 19:05:29 +08:00
parent 1d0b4c9fbb
commit fd5205e33e
44 changed files with 3730 additions and 278 deletions

View File

@@ -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

View File

@@ -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钉钉回调时为审批人真实身份TokenMES内部审批时为null
* 供 ApprovalActionHttpExecutor 等需要身份的调用方使用。
*/
private transient String token;
/**
* 钉钉任务节点IDoperationRecords[].activityId仅钉钉通道有值
* 可供集成引擎或业务回调按节点精确匹配。
*/
private String activityId;
//update-end---author:GHT ---date:2026-06-08 for【缺陷修复-D1/D2】新增token和activityId字段支持钉钉回调时传递真实审批人身份及节点精确定位-----------
}

View File

@@ -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;
}

View File

@@ -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时通知业务回滚中间态状态-----------
}

View File

@@ -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;

View File

@@ -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<MesXslApprova
HttpServletRequest req) {
QueryWrapper<MesXslApprovalTrace> qw = QueryGenerator.initQueryWrapper(model, req.getParameterMap());
qw.orderByDesc("update_time").orderByDesc("create_time");
return Result.OK(traceService.page(new Page<>(pageNo, pageSize), qw));
//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<MesXslApprova
MesXslApprovalTrace entity = traceService.getByBiz(bizTable, bizDataId);
return Result.OK(entity);
}
//update-begin---author:GHT ---date:20260608 for【审批注册中心】查看钉钉审批流转记录-----------
@Operation(summary = "审批痕迹-钉钉审批流转记录(时间轴)")
@RequiresPermissions(value = {"xslmes:mes_xsl_approval_trace:list", "xslmes:mes_xsl_biz_doc_registry:trace"}, logical = Logical.OR)
@GetMapping("/dingFlowRecords")
public Result<DingProcessInstanceFlowVO> dingFlowRecords(
@RequestParam(required = false) String bizTable,
@RequestParam(required = false) String bizDataId,
@RequestParam(required = false) String processInstanceId) {
if (oConvertUtils.isEmpty(processInstanceId)
&& (oConvertUtils.isEmpty(bizTable) || oConvertUtils.isEmpty(bizDataId))) {
return Result.error("单据ID与钉钉审批流ID不能同时为空");
}
try {
return Result.OK(traceService.getDingFlowRecords(bizTable, bizDataId, processInstanceId));
} catch (IllegalArgumentException e) {
return Result.error(e.getMessage());
} catch (IllegalStateException e) {
return Result.error(e.getMessage());
} catch (Exception e) {
log.error("拉取钉钉审批流转记录失败 bizTable={} bizDataId={} processInstanceId={}: {}",
bizTable, bizDataId, processInstanceId, e.getMessage(), e);
return Result.error("拉取钉钉审批流转记录失败:" + e.getMessage());
}
}
//update-end---author:GHT ---date:20260608 for【审批注册中心】查看钉钉审批流转记录-----------
//update-begin---author:GHT ---date:20260608 for【审批注册中心】审批节点改由实例tasks按activityId解析-----------
@Operation(summary = "审批痕迹-钉钉审批节点(实例tasks解析)")
@RequiresPermissions(value = {"xslmes:mes_xsl_approval_trace:list", "xslmes:mes_xsl_biz_doc_registry:trace"}, logical = Logical.OR)
@GetMapping("/dingProcessForecast")
public Result<DingProcessForecastVO> dingProcessForecast(
@RequestParam(required = false) String bizTable,
@RequestParam(required = false) String bizDataId,
@RequestParam(required = false) String processInstanceId) {
if (oConvertUtils.isEmpty(processInstanceId)
&& (oConvertUtils.isEmpty(bizTable) || oConvertUtils.isEmpty(bizDataId))) {
return Result.error("单据ID与钉钉审批流ID不能同时为空");
}
try {
return Result.OK(traceService.getDingProcessForecast(bizTable, bizDataId, processInstanceId));
} catch (IllegalArgumentException e) {
return Result.error(e.getMessage());
} catch (IllegalStateException e) {
return Result.error(e.getMessage());
} catch (Exception e) {
log.error("获取钉钉审批节点失败 bizTable={} bizDataId={} processInstanceId={}: {}",
bizTable, bizDataId, processInstanceId, e.getMessage(), e);
return Result.error("获取钉钉审批节点失败:" + e.getMessage());
}
}
//update-end---author:GHT ---date:20260608 for【审批注册中心】审批节点改由实例tasks按activityId解析-----------
//update-begin---author:GHT ---date:20260608 for【审批注册中心】查看钉钉审批实例原始JSON-----------
@Operation(summary = "审批痕迹-钉钉审批实例原始JSON")
@RequiresPermissions(value = {"xslmes:mes_xsl_approval_trace:list", "xslmes:mes_xsl_biz_doc_registry:trace"}, logical = Logical.OR)
@GetMapping("/dingProcessInstance")
public Result<JSONObject> dingProcessInstance(
@RequestParam(required = false) String bizTable,
@RequestParam(required = false) String bizDataId,
@RequestParam(required = false) String processInstanceId) {
if (oConvertUtils.isEmpty(processInstanceId)
&& (oConvertUtils.isEmpty(bizTable) || oConvertUtils.isEmpty(bizDataId))) {
return Result.error("单据ID与钉钉审批流ID不能同时为空");
}
try {
return Result.OK(traceService.getDingProcessInstance(bizTable, bizDataId, processInstanceId));
} catch (IllegalArgumentException e) {
return Result.error(e.getMessage());
} catch (IllegalStateException e) {
return Result.error(e.getMessage());
} catch (Exception e) {
log.error("拉取钉钉审批实例原始JSON失败 bizTable={} bizDataId={} processInstanceId={}: {}",
bizTable, bizDataId, processInstanceId, e.getMessage(), e);
return Result.error("拉取钉钉审批实例失败:" + e.getMessage());
}
}
//update-end---author:GHT ---date:20260608 for【审批注册中心】查看钉钉审批实例原始JSON-----------
}

View File

@@ -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<MesXslInteg
@RequiresPermissions("xslmes:mes_xsl_integration_plan:edit")
@PostMapping("/action/add")
public Result<String> addAction(@RequestBody MesXslIntegrationAction action) {
normalizeRegistryAction(action);
actionService.save(action);
return Result.OK("添加成功");
}
@@ -202,10 +204,24 @@ public class MesXslIntegrationPlanController extends JeecgController<MesXslInteg
@RequiresPermissions("xslmes:mes_xsl_integration_plan:edit")
@PutMapping("/action/edit")
public Result<String> editAction(@RequestBody MesXslIntegrationAction action) {
normalizeRegistryAction(action);
actionService.updateById(action);
return Result.OK("编辑成功");
}
//update-begin---author:GHT ---date:20260608 for【审核集成】环节同步/回退动作保存时清理无效SQL模板-----------
/** REGISTRY 类动作不走 SQL_UPDATE保存时强制清空 sql_template 避免脏数据 */
private void normalizeRegistryAction(MesXslIntegrationAction action) {
if (action == null || oConvertUtils.isEmpty(action.getActionType())) {
return;
}
if ("REGISTRY_STAGE_SYNC".equals(action.getActionType())
|| "REGISTRY_STAGE_REVERT".equals(action.getActionType())) {
action.setSqlTemplate(null);
}
}
//update-end---author:GHT ---date:20260608 for【审核集成】环节同步/回退动作保存时清理无效SQL模板-----------
@Operation(summary = "动作-删除")
@RequiresPermissions("xslmes:mes_xsl_integration_plan:edit")
@DeleteMapping("/action/delete")

View File

@@ -0,0 +1,594 @@
package org.jeecg.modules.xslmes.approval.integration.engine;
import com.alibaba.fastjson2.JSONArray;
import com.alibaba.fastjson2.JSONObject;
import lombok.Data;
import lombok.experimental.Accessors;
import org.jeecg.common.util.oConvertUtils;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Component;
import java.text.ParseException;
import java.text.SimpleDateFormat;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Comparator;
import java.util.Date;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.stream.Collectors;
/**
* 从钉钉审批实例 tasks 按 activityId 解析环节完成情况,并与 MES 审批流节点 stageKey / multiMode 对齐。
*/
@Component
public class ApprovalInstanceStageExtractor {
private static final Set<String> TRACE_STAGES = Set.of(
ApprovalStageResolver.STAGE_PROOFREAD,
ApprovalStageResolver.STAGE_AUDIT,
ApprovalStageResolver.STAGE_APPROVE);
private final JdbcTemplate jdbcTemplate;
public ApprovalInstanceStageExtractor(JdbcTemplate jdbcTemplate) {
this.jdbcTemplate = jdbcTemplate;
}
//update-begin---author:GHT ---date:20260608 for【审批注册中心】按MES multiMode解析节点状态与审批人-----------
/**
* 按 MES 审批流节点顺序与实例 tasks 的 activityId 顺序对齐,解析各环节已完成操作人及最新时间。
*/
public List<StageCompletion> resolveCompletedStages(JSONObject instance, String flowConfig) {
List<StageCompletion> completions = new ArrayList<>();
if (instance == null || oConvertUtils.isEmpty(flowConfig)) {
return completions;
}
List<NodePair> pairs = alignMesNodesWithTasks(instance, flowConfig);
for (NodePair pair : pairs) {
String stageKey = resolveStageKey(pair.getMesNode());
if (!isTraceStage(stageKey)) {
continue;
}
NodeTaskDecision decision = evaluateNodeTasks(pair.getTaskList(), resolveApprovalMethod(pair.getMesNode()));
if (!decision.isAgreed()) {
continue;
}
StageCompletion completion = toStageCompletion(stageKey, pair.getActivityId(), decision);
if (completion != null) {
completions.add(completion);
}
}
return completions;
}
public LinkedHashMap<String, List<JSONObject>> groupTasksByActivityId(JSONObject instance) {
LinkedHashMap<String, List<JSONObject>> grouped = new LinkedHashMap<>();
JSONArray tasks = instance.getJSONArray("tasks");
if (tasks == null || tasks.isEmpty()) {
return grouped;
}
for (int i = 0; i < tasks.size(); i++) {
JSONObject task = tasks.getJSONObject(i);
if (task == null) {
continue;
}
String activityId = task.getString("activityId");
if (oConvertUtils.isEmpty(activityId)) {
continue;
}
grouped.computeIfAbsent(activityId, k -> new ArrayList<>()).add(task);
}
return grouped;
}
public List<String> listOrderedActivityIds(JSONObject instance) {
return new ArrayList<>(groupTasksByActivityId(instance).keySet());
}
public int resolveStepIndexFromTasks(JSONObject instance, String activityId) {
if (instance == null || oConvertUtils.isEmpty(activityId)) {
return -1;
}
return listOrderedActivityIds(instance).indexOf(activityId);
}
/**
* 提取某 activityId 节点完成时的审批人及时间(按 MES multiMode 判定)。
*/
public StageCompletion extractActivityCompletion(JSONObject instance, String activityId, JSONObject mesNode) {
if (instance == null || oConvertUtils.isEmpty(activityId)) {
return null;
}
List<JSONObject> taskList = groupTasksByActivityId(instance).get(activityId);
String approvalMethod = mesNode == null ? "NONE" : resolveApprovalMethod(mesNode);
NodeTaskDecision decision = evaluateNodeTasks(taskList, approvalMethod);
if (!decision.isAgreed()) {
return null;
}
return toStageCompletion(null, activityId, decision);
}
public List<JSONObject> loadMesApproverNodes(String flowConfig) {
List<JSONObject> result = new ArrayList<>();
if (oConvertUtils.isEmpty(flowConfig)) {
return result;
}
try {
collectAllApproverNodes(JSONObject.parseObject(flowConfig), result);
} catch (Exception ignored) {
// 解析失败返回空列表
}
return result;
}
public List<NodePair> alignMesNodesWithTasks(JSONObject instance, String flowConfig) {
List<NodePair> pairs = new ArrayList<>();
LinkedHashMap<String, List<JSONObject>> grouped = groupTasksByActivityId(instance);
if (grouped.isEmpty()) {
return pairs;
}
List<JSONObject> mesNodes = loadMesApproverNodes(flowConfig);
if (mesNodes.isEmpty()) {
return pairs;
}
List<String> activityOrder = new ArrayList<>(grouped.keySet());
int pairCount = Math.min(mesNodes.size(), activityOrder.size());
for (int i = 0; i < pairCount; i++) {
NodePair pair = new NodePair();
pair.setStepNo(i + 1);
pair.setMesNode(mesNodes.get(i));
pair.setActivityId(activityOrder.get(i));
pair.setTaskList(grouped.get(activityOrder.get(i)));
pairs.add(pair);
}
return pairs;
}
public String resolveStageKey(JSONObject mesNode) {
if (mesNode == null) {
return null;
}
JSONObject props = mesNode.getJSONObject("props");
if (props == null) {
return null;
}
String stageKey = props.getString("stageKey");
return oConvertUtils.isEmpty(stageKey) ? null : stageKey.trim();
}
/** 从 MES 审批流节点 props.multiMode 映射钉钉审批方式 */
public String resolveApprovalMethod(JSONObject mesNode) {
if (mesNode == null) {
return "NONE";
}
JSONObject props = mesNode.getJSONObject("props");
if (props == null) {
return "NONE";
}
String multiMode = props.getString("multiMode");
if (oConvertUtils.isEmpty(multiMode) || "none".equalsIgnoreCase(multiMode)) {
return "NONE";
}
if ("or".equalsIgnoreCase(multiMode)) {
return "OR";
}
if ("and".equalsIgnoreCase(multiMode)) {
return "AND";
}
if ("sequence".equalsIgnoreCase(multiMode)) {
return "ONE_BY_ONE";
}
return "NONE";
}
public String approvalMethodText(String approvalMethod) {
if (oConvertUtils.isEmpty(approvalMethod)) {
return "单人审批";
}
return switch (approvalMethod.toUpperCase()) {
case "AND" -> "会签";
case "OR" -> "或签";
case "ONE_BY_ONE" -> "依次审批";
case "NONE" -> "单人审批";
default -> approvalMethod;
};
}
/**
* 按审批方式解析节点状态与应展示的审批人。
* 或签:任一通过/拒绝即定论,只取实际操作人;会签:全部通过才完成,取全部通过人。
*/
public NodeTaskDecision evaluateNodeTasks(List<JSONObject> taskList, String approvalMethod) {
NodeTaskDecision decision = new NodeTaskDecision();
decision.setNodeStatus("UNKNOWN");
if (taskList == null || taskList.isEmpty()) {
decision.setNodeStatus("NEW");
decision.setNodeStatusText(nodeStatusText("NEW"));
return decision;
}
String method = normalizeApprovalMethod(approvalMethod);
JSONObject refuseTask = findFirstActedTask(taskList, "REFUSE");
if (refuseTask != null) {
decision.setNodeStatus("REFUSED");
decision.setNodeStatusText(nodeStatusText("REFUSED"));
decision.setRefused(true);
decision.setActorUserIds(List.of(refuseTask.getString("userId")));
decision.setOperatorTime(parseFinishTime(refuseTask.getString("finishTime")));
return decision;
}
if ("OR".equals(method)) {
JSONObject agreeTask = findFirstActedTask(taskList, "AGREE");
if (agreeTask != null) {
decision.setNodeStatus("COMPLETED");
decision.setNodeStatusText(nodeStatusText("COMPLETED"));
decision.setAgreed(true);
decision.setActorUserIds(List.of(agreeTask.getString("userId")));
decision.setOperatorTime(parseFinishTime(agreeTask.getString("finishTime")));
return decision;
}
return decisionFromPendingTasks(taskList);
}
if ("AND".equals(method) || "ONE_BY_ONE".equals(method)) {
if (hasRunningOrNew(taskList)) {
decision.setNodeStatus("RUNNING");
decision.setNodeStatusText(nodeStatusText("RUNNING"));
decision.setActorUserIds(listAllAssigneeIds(taskList));
return decision;
}
List<JSONObject> agreeTasks = findAllActedTasks(taskList, "AGREE");
int activeCount = countActiveTasks(taskList);
if (activeCount > 0 && agreeTasks.size() >= activeCount) {
decision.setNodeStatus("COMPLETED");
decision.setNodeStatusText(nodeStatusText("COMPLETED"));
decision.setAgreed(true);
decision.setActorUserIds(extractOrderedUserIds(agreeTasks));
decision.setOperatorTime(latestFinishTime(agreeTasks));
return decision;
}
return decisionFromPendingTasks(taskList);
}
// 单人审批
JSONObject agreeTask = findFirstActedTask(taskList, "AGREE");
if (agreeTask != null) {
decision.setNodeStatus("COMPLETED");
decision.setNodeStatusText(nodeStatusText("COMPLETED"));
decision.setAgreed(true);
decision.setActorUserIds(List.of(agreeTask.getString("userId")));
decision.setOperatorTime(parseFinishTime(agreeTask.getString("finishTime")));
return decision;
}
if (hasRunningOrNew(taskList)) {
decision.setNodeStatus("RUNNING");
decision.setNodeStatusText(nodeStatusText("RUNNING"));
decision.setActorUserIds(listAllAssigneeIds(taskList));
return decision;
}
return decisionFromPendingTasks(taskList);
}
public boolean isNodeCompleted(List<JSONObject> taskList, String approvalMethod) {
NodeTaskDecision decision = evaluateNodeTasks(taskList, approvalMethod);
return decision.isAgreed();
}
/** 审批实例是否已拒绝或终止(此时不应反写已通过环节的痕迹) */
public boolean isInstanceRejectedOrCancelled(JSONObject instance) {
if (instance == null) {
return false;
}
String result = instance.getString("result");
if (oConvertUtils.isNotEmpty(result) && "refuse".equalsIgnoreCase(result.trim())) {
return true;
}
String status = instance.getString("status");
if (oConvertUtils.isEmpty(status)) {
return false;
}
String normalized = status.trim().toUpperCase();
return "TERMINATED".equals(normalized) || "CANCELED".equals(normalized) || "CANCELLED".equals(normalized);
}
public String nodeStatusText(String nodeStatus) {
if (oConvertUtils.isEmpty(nodeStatus)) {
return "未知";
}
return switch (nodeStatus.toUpperCase()) {
case "COMPLETED" -> "已完成";
case "RUNNING" -> "进行中";
case "REFUSED" -> "已拒绝";
case "CANCELED" -> "已取消";
case "NEW" -> "未启动";
default -> nodeStatus;
};
}
public List<String> resolveActorNames(List<String> dtUserIds) {
if (dtUserIds == null || dtUserIds.isEmpty()) {
return new ArrayList<>();
}
Map<String, String> nameMap = batchResolveDtUserDisplayNames(dtUserIds);
return dtUserIds.stream().map(id -> nameMap.getOrDefault(id, id)).collect(Collectors.toList());
}
private NodeTaskDecision decisionFromPendingTasks(List<JSONObject> taskList) {
NodeTaskDecision decision = new NodeTaskDecision();
if (hasRunningOrNew(taskList)) {
decision.setNodeStatus("RUNNING");
decision.setNodeStatusText(nodeStatusText("RUNNING"));
decision.setActorUserIds(listAllAssigneeIds(taskList));
return decision;
}
if (allCanceled(taskList)) {
decision.setNodeStatus("CANCELED");
decision.setNodeStatusText(nodeStatusText("CANCELED"));
decision.setActorUserIds(listAllAssigneeIds(taskList));
return decision;
}
decision.setNodeStatus("NEW");
decision.setNodeStatusText(nodeStatusText("NEW"));
decision.setActorUserIds(listAllAssigneeIds(taskList));
return decision;
}
private StageCompletion toStageCompletion(String stageKey, String activityId, NodeTaskDecision decision) {
if (decision == null || !decision.isAgreed() || decision.getActorUserIds() == null
|| decision.getActorUserIds().isEmpty()) {
return null;
}
List<String> names = resolveActorNames(decision.getActorUserIds());
StageCompletion completion = new StageCompletion();
completion.setStage(stageKey);
completion.setActivityId(activityId);
completion.setOperatorBy(String.join("", names));
completion.setOperatorTime(decision.getOperatorTime() == null ? new Date() : decision.getOperatorTime());
completion.setDtUserIds(decision.getActorUserIds());
return completion;
}
private String normalizeApprovalMethod(String approvalMethod) {
return oConvertUtils.isEmpty(approvalMethod) ? "NONE" : approvalMethod.trim().toUpperCase();
}
private JSONObject findFirstActedTask(List<JSONObject> taskList, String result) {
return taskList.stream()
.filter(task -> task != null && "COMPLETED".equalsIgnoreCase(task.getString("status")))
.filter(task -> result.equalsIgnoreCase(task.getString("result")))
.min(Comparator.comparing(task -> {
Date time = parseFinishTime(task.getString("finishTime"));
return time == null ? new Date(Long.MAX_VALUE) : time;
}))
.orElse(null);
}
private List<JSONObject> findAllActedTasks(List<JSONObject> taskList, String result) {
List<JSONObject> list = new ArrayList<>();
for (JSONObject task : taskList) {
if (task == null) {
continue;
}
if ("COMPLETED".equalsIgnoreCase(task.getString("status"))
&& result.equalsIgnoreCase(task.getString("result"))) {
list.add(task);
}
}
return list;
}
private List<String> extractOrderedUserIds(List<JSONObject> tasks) {
List<String> ids = new ArrayList<>();
for (JSONObject task : tasks) {
String uid = task.getString("userId");
if (oConvertUtils.isNotEmpty(uid) && !ids.contains(uid)) {
ids.add(uid);
}
}
return ids;
}
private List<String> listAllAssigneeIds(List<JSONObject> taskList) {
List<String> ids = new ArrayList<>();
for (JSONObject task : taskList) {
if (task == null) {
continue;
}
String uid = task.getString("userId");
if (oConvertUtils.isNotEmpty(uid) && !ids.contains(uid)) {
ids.add(uid);
}
}
return ids;
}
private boolean hasRunningOrNew(List<JSONObject> taskList) {
for (JSONObject task : taskList) {
if (task == null) {
continue;
}
String status = task.getString("status");
if ("RUNNING".equalsIgnoreCase(status) || "NEW".equalsIgnoreCase(status)) {
return true;
}
}
return false;
}
private boolean allCanceled(List<JSONObject> taskList) {
for (JSONObject task : taskList) {
if (task == null) {
continue;
}
if (!"CANCELED".equalsIgnoreCase(task.getString("status"))) {
return false;
}
}
return true;
}
private int countActiveTasks(List<JSONObject> taskList) {
int count = 0;
for (JSONObject task : taskList) {
if (task == null) {
continue;
}
if (!"CANCELED".equalsIgnoreCase(task.getString("status"))) {
count++;
}
}
return count;
}
private Date latestFinishTime(List<JSONObject> tasks) {
Date latest = null;
for (JSONObject task : tasks) {
Date time = parseFinishTime(task.getString("finishTime"));
if (time != null && (latest == null || time.after(latest))) {
latest = time;
}
}
return latest;
}
private boolean isTraceStage(String stageKey) {
return oConvertUtils.isNotEmpty(stageKey) && TRACE_STAGES.contains(stageKey);
}
private void collectAllApproverNodes(JSONObject node, List<JSONObject> out) {
if (node == null) {
return;
}
if ("approver".equals(node.getString("type"))) {
out.add(node);
}
JSONArray branches = node.getJSONArray("conditionNodes");
if (branches != null) {
for (int i = 0; i < branches.size(); i++) {
Object branch = branches.get(i);
if (branch instanceof JSONObject branchObj) {
collectAllApproverNodes(branchObj.getJSONObject("childNode"), out);
}
}
}
collectAllApproverNodes(node.getJSONObject("childNode"), out);
}
private Date parseFinishTime(String finishTime) {
if (oConvertUtils.isEmpty(finishTime)) {
return null;
}
String[] patterns = {"yyyy-MM-dd HH:mm:ss", "yyyy-MM-dd'T'HH:mm:ss", "yyyy-MM-dd'T'HH:mm:ss.SSS'Z'"};
for (String pattern : patterns) {
try {
return new SimpleDateFormat(pattern).parse(finishTime.trim());
} catch (ParseException ignored) {
// 尝试下一种格式
}
}
return null;
}
private Map<String, String> batchResolveDtUserDisplayNames(Collection<String> dtUserIds) {
Map<String, String> result = new HashMap<>();
if (dtUserIds == null || dtUserIds.isEmpty()) {
return result;
}
List<String> ids = dtUserIds.stream().filter(oConvertUtils::isNotEmpty).distinct().collect(Collectors.toList());
if (ids.isEmpty()) {
return result;
}
String inClause = ids.stream().map(id -> "?").collect(Collectors.joining(","));
try {
List<Map<String, Object>> localRows = jdbcTemplate.queryForList(
"SELECT ding_user_id, realname, username FROM sys_user "
+ "WHERE ding_user_id IN (" + inClause + ") AND (del_flag=0 OR del_flag IS NULL)",
ids.toArray());
for (Map<String, Object> row : localRows) {
String dtId = stringValue(row.get("ding_user_id"));
if (oConvertUtils.isEmpty(dtId)) {
continue;
}
result.put(dtId, pickDisplayName(stringValue(row.get("realname")), stringValue(row.get("username")), dtId));
}
} catch (Exception ignored) {
// 查询失败时降级保留钉钉ID
}
List<String> missing = ids.stream().filter(id -> !result.containsKey(id)).collect(Collectors.toList());
if (!missing.isEmpty()) {
String missingIn = missing.stream().map(id -> "?").collect(Collectors.joining(","));
try {
List<Map<String, Object>> thirdRows = jdbcTemplate.queryForList(
"SELECT t.third_user_id, u.realname, u.username "
+ "FROM sys_third_account t "
+ "JOIN sys_user u ON u.id = t.sys_user_id "
+ "WHERE t.third_type='dingtalk' AND t.third_user_id IN (" + missingIn + ") "
+ "AND (t.del_flag=0 OR t.del_flag IS NULL) AND (u.del_flag=0 OR u.del_flag IS NULL)",
missing.toArray());
for (Map<String, Object> row : thirdRows) {
String dtId = stringValue(row.get("third_user_id"));
if (oConvertUtils.isEmpty(dtId) || result.containsKey(dtId)) {
continue;
}
result.put(dtId, pickDisplayName(stringValue(row.get("realname")), stringValue(row.get("username")), dtId));
}
} catch (Exception ignored) {
// 查询失败时降级保留钉钉ID
}
}
for (String id : ids) {
result.putIfAbsent(id, id);
}
return result;
}
private String pickDisplayName(String realname, String username, String fallback) {
if (oConvertUtils.isNotEmpty(realname)) {
return realname;
}
if (oConvertUtils.isNotEmpty(username)) {
return username;
}
return fallback;
}
private String stringValue(Object value) {
return value == null ? null : String.valueOf(value);
}
//update-end---author:GHT ---date:20260608 for【审批注册中心】按MES multiMode解析节点状态与审批人-----------
@Data
@Accessors(chain = true)
public static class StageCompletion {
private String stage;
private String activityId;
private String operatorBy;
private Date operatorTime;
private List<String> dtUserIds;
}
@Data
@Accessors(chain = true)
public static class NodePair {
private int stepNo;
private JSONObject mesNode;
private String activityId;
private List<JSONObject> taskList;
}
@Data
@Accessors(chain = true)
public static class NodeTaskDecision {
private String nodeStatus;
private String nodeStatusText;
private List<String> actorUserIds = new ArrayList<>();
private Date operatorTime;
private boolean agreed;
private boolean refused;
}
}

View File

@@ -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";
}
}

View File

@@ -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<String, Object> sourceRecord = loadSourceRecord(ctx.getSourceBizTable(), ctx.getSourceBizId());
if (sourceRecord != null) {
ctx.setSourceRecord(sourceRecord);
}
}
//update-end---author:GHT ---date:20260608 for【审核集成】多动作串行执行时刷新源单上下文-----------
//update-begin---author:GHT ---date:20260605 for【XSLMES-20260605-K8R2】修复台账查找兼容钉钉recordId与MES外部实例ID-----------
private MesXslApprovalRecord findRecord(ApprovalCallbackContext approvalCtx) {
try {
@@ -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<MesXslIntegrationAction> 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);

View File

@@ -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);

View File

@@ -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】审批注册中心环节同步执行器-----------
}

View File

@@ -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;
}

View File

@@ -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;

View File

@@ -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【审批注册中心】拒绝/终止时清空源单与痕迹操作人-----------
}

View File

@@ -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<MesXslApprovalTrac
* 按业务表 + 单据ID 查询痕迹(供业务页关联展示)
*/
MesXslApprovalTrace getByBiz(String bizTable, String bizDataId);
//update-begin---author:GHT ---date:20260608 for【审批注册中心】明细列表补充钉钉审批实例ID-----------
/**
* 分页查询并补充钉钉审批实例ID
*/
IPage<MesXslApprovalTrace> pageWithDingInstanceId(IPage<MesXslApprovalTrace> page, Wrapper<MesXslApprovalTrace> wrapper);
/**
* 批量补充钉钉审批实例ID
*/
void enrichExternalInstanceIds(List<MesXslApprovalTrace> traces);
//update-end---author:GHT ---date:20260608 for【审批注册中心】明细列表补充钉钉审批实例ID-----------
//update-begin---author:GHT ---date:20260608 for【审批注册中心】查看钉钉审批流转记录-----------
/**
* 按业务单据或钉钉实例ID拉取审批流转操作记录
*/
DingProcessInstanceFlowVO getDingFlowRecords(String bizTable, String bizDataId, String processInstanceId);
//update-end---author:GHT ---date:20260608 for【审批注册中心】查看钉钉审批流转记录-----------
//update-begin---author:GHT ---date:20260608 for【审批注册中心】审批节点改由实例tasks按activityId解析-----------
/**
* 按业务单据或钉钉实例ID拉取审批实例从 tasks 按 activityId 解析审批节点
*/
DingProcessForecastVO getDingProcessForecast(String bizTable, String bizDataId, String processInstanceId);
//update-end---author:GHT ---date:20260608 for【审批注册中心】审批节点改由实例tasks按activityId解析-----------
//update-begin---author:GHT ---date:20260608 for【审批注册中心】查看钉钉审批实例原始JSON-----------
/**
* 按业务单据或钉钉实例ID拉取审批实例接口原始 JSON 响应
*/
JSONObject getDingProcessInstance(String bizTable, String bizDataId, String processInstanceId);
//update-end---author:GHT ---date:20260608 for【审批注册中心】查看钉钉审批实例原始JSON-----------
}

View File

@@ -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<StageCompletion> completions = instanceStageExtractor.resolveCompletedStages(instance, flowConfig);
if (completions.isEmpty()) {
return;
}
for (StageCompletion completion : completions) {
if (completion == null || oConvertUtils.isEmpty(completion.getStage())) {
continue;
}
String stageErr = checkStageAllowed(bizTable, completion.getStage());
if (stageErr != null) {
continue;
}
syncStage(bizTable, bizDataId, completion.getStage(), completion.getOperatorBy(), completion.getOperatorTime());
//update-begin---author:GHT ---date:20260608 for【缺陷修复-会签集成】移除补偿路径对源单的直接修改源单状态变更由集成方案动作RegistryStageSyncExecutor负责-----------
// 此处不再调用 updateBizStageFields源单状态字段必须经集成方案动作统一变更
// 补偿反写backfillTraceFromDingInstances只负责更新审批痕迹表
// 避免绕过集成方案导致第二条及后续动作(如关联表 SQL_UPDATE无法执行。
//update-end---author:GHT ---date:20260608 for【缺陷修复-会签集成】移除补偿路径对源单的直接修改源单状态变更由集成方案动作RegistryStageSyncExecutor负责-----------
}
log.info("[审批痕迹反写] 完成 table={} bizId={} instanceId={} stages={}",
bizTable, bizDataId, processInstanceId,
completions.stream().map(StageCompletion::getStage).reduce((a, b) -> a + "," + b).orElse(""));
}
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<Object> 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<Object> 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<Object> params, String field) {
if (oConvertUtils.isEmpty(field)) {
return;
}
RegistryStageFieldHelper.assertIdentifier(field);
sql.append(", `").append(field).append("`=?");
params.add(null);
}
//update-end---author:GHT ---date:20260608 for【审批注册中心】拒绝/终止时清空源单与痕迹操作人-----------
private MesXslApprovalTrace findTraceByBiz(String bizTable, String bizDataId) {
if (oConvertUtils.isEmpty(bizTable) || oConvertUtils.isEmpty(bizDataId)) {
return null;
}
return traceMapper.selectOne(new LambdaQueryWrapper<MesXslApprovalTrace>()
.eq(MesXslApprovalTrace::getBizTable, bizTable)
.eq(MesXslApprovalTrace::getBizDataId, bizDataId)
.last("LIMIT 1"));
}
private void saveOrUpdateTrace(MesXslApprovalTrace trace) {
if (oConvertUtils.isEmpty(trace.getId())) {
traceMapper.insert(trace);
} else {
traceMapper.updateById(trace);
}
}
private MesXslBizDocRegistry findActiveRegistry(String bizTable) {

View File

@@ -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<MesXslApprovalTraceMapper, MesXslApprovalTrace>
implements IMesXslApprovalTraceService {
@Autowired
private IMesXslApprovalRecordService approvalRecordService;
@Autowired
private DingTalkWorkflowService dingTalkWorkflowService;
@Autowired
private JdbcTemplate jdbcTemplate;
@Autowired
private IMesXslDingProcessTplService dingProcessTplService;
@Autowired
private IMesXslApprovalFlowService approvalFlowService;
@Autowired
private DingApprovalLaunchParamBuilder launchParamBuilder;
@Autowired
private IApprovalTraceSyncService approvalTraceSyncService;
@Autowired
private ApprovalInstanceStageExtractor instanceStageExtractor;
@Override
public MesXslApprovalTrace getByBiz(String bizTable, String bizDataId) {
if (oConvertUtils.isEmpty(bizTable) || oConvertUtils.isEmpty(bizDataId)) {
@@ -24,4 +83,497 @@ public class MesXslApprovalTraceServiceImpl extends ServiceImpl<MesXslApprovalTr
.eq(MesXslApprovalTrace::getBizDataId, bizDataId)
.one();
}
//update-begin---author:GHT ---date:20260608 for【审批注册中心】明细列表补充钉钉审批实例ID-----------
@Override
public IPage<MesXslApprovalTrace> pageWithDingInstanceId(IPage<MesXslApprovalTrace> page, Wrapper<MesXslApprovalTrace> wrapper) {
IPage<MesXslApprovalTrace> result = page(page, wrapper);
enrichExternalInstanceIds(result.getRecords());
//update-begin---author:GHT ---date:20260608 for【审批注册中心】列表补偿反写主路径为钉钉回调→集成方案编排-----------
backfillTraceFromDingInstances(result.getRecords());
//update-end---author:GHT ---date:20260608 for【审批注册中心】列表补偿反写主路径为钉钉回调→集成方案编排-----------
return result;
}
//update-begin---author:GHT ---date:20260608 for【审批注册中心】列表加载时按实例tasks反写痕迹-----------
private void backfillTraceFromDingInstances(List<MesXslApprovalTrace> traces) {
if (traces == null || traces.isEmpty()) {
return;
}
for (MesXslApprovalTrace trace : traces) {
if (trace == null || oConvertUtils.isEmpty(trace.getExternalInstanceId())) {
continue;
}
MesXslApprovalRecord dingRecord = findLatestDingRecord(trace.getBizTable(), trace.getBizDataId());
if (dingRecord == null || oConvertUtils.isEmpty(dingRecord.getFlowId())) {
continue;
}
MesXslApprovalFlow mesFlow = resolveMesFlow(dingRecord);
if (mesFlow == null || oConvertUtils.isEmpty(mesFlow.getFlowConfig())) {
continue;
}
try {
//update-begin---author:GHT ---date:20260608 for【审批注册中心】台账已拒绝/终止时优先清空痕迹-----------
if (ApprovalRecordConstants.STATUS_REJECTED.equals(dingRecord.getStatus())
|| ApprovalRecordConstants.STATUS_CANCELLED.equals(dingRecord.getStatus())) {
approvalTraceSyncService.revertToCompile(trace.getBizTable(), trace.getBizDataId());
clearTraceFields(trace);
continue;
}
//update-end---author:GHT ---date:20260608 for【审批注册中心】台账已拒绝/终止时优先清空痕迹-----------
approvalTraceSyncService.syncFromDingInstance(
trace.getBizTable(), trace.getBizDataId(), trace.getExternalInstanceId(), mesFlow.getFlowConfig());
MesXslApprovalTrace latest = getByBiz(trace.getBizTable(), trace.getBizDataId());
if (latest != null) {
trace.setProofreadBy(latest.getProofreadBy());
trace.setProofreadTime(latest.getProofreadTime());
trace.setAuditBy(latest.getAuditBy());
trace.setAuditTime(latest.getAuditTime());
trace.setApproveBy(latest.getApproveBy());
trace.setApproveTime(latest.getApproveTime());
}
} catch (Exception e) {
log.warn("[审批痕迹反写] 列表反写失败 table={} bizId={} instanceId={}: {}",
trace.getBizTable(), trace.getBizDataId(), trace.getExternalInstanceId(), e.getMessage());
}
}
}
private void clearTraceFields(MesXslApprovalTrace trace) {
if (trace == null) {
return;
}
trace.setProofreadBy(null);
trace.setProofreadTime(null);
trace.setAuditBy(null);
trace.setAuditTime(null);
trace.setApproveBy(null);
trace.setApproveTime(null);
}
//update-end---author:GHT ---date:20260608 for【审批注册中心】列表加载时按实例tasks反写痕迹-----------
@Override
public void enrichExternalInstanceIds(List<MesXslApprovalTrace> traces) {
if (traces == null || traces.isEmpty()) {
return;
}
Set<String> bizDataIds = traces.stream()
.map(MesXslApprovalTrace::getBizDataId)
.filter(oConvertUtils::isNotEmpty)
.collect(Collectors.toCollection(HashSet::new));
if (bizDataIds.isEmpty()) {
return;
}
Set<String> bizTables = traces.stream()
.map(MesXslApprovalTrace::getBizTable)
.filter(oConvertUtils::isNotEmpty)
.collect(Collectors.toCollection(HashSet::new));
if (bizTables.isEmpty()) {
return;
}
List<MesXslApprovalRecord> records = approvalRecordService.lambdaQuery()
.in(MesXslApprovalRecord::getBizTable, bizTables)
.in(MesXslApprovalRecord::getBizDataId, bizDataIds)
.eq(MesXslApprovalRecord::getChannel, ApprovalRecordConstants.CHANNEL_DINGTALK)
.orderByDesc(MesXslApprovalRecord::getLaunchNo)
.orderByDesc(MesXslApprovalRecord::getApplyTime)
.list();
Map<String, String> instanceIdMap = new HashMap<>();
for (MesXslApprovalRecord record : records) {
if (record == null || oConvertUtils.isEmpty(record.getExternalInstanceId())) {
continue;
}
String key = buildBizKey(record.getBizTable(), record.getBizDataId());
instanceIdMap.putIfAbsent(key, record.getExternalInstanceId());
}
for (MesXslApprovalTrace trace : traces) {
if (trace == null) {
continue;
}
String key = buildBizKey(trace.getBizTable(), trace.getBizDataId());
trace.setExternalInstanceId(instanceIdMap.get(key));
}
}
//update-end---author:GHT ---date:20260608 for【审批注册中心】明细列表补充钉钉审批实例ID-----------
//update-begin---author:GHT ---date:20260608 for【审批注册中心】查看钉钉审批流转记录-----------
@Override
public DingProcessInstanceFlowVO getDingFlowRecords(String bizTable, String bizDataId, String processInstanceId) {
String instanceId = processInstanceId;
if (oConvertUtils.isEmpty(instanceId)) {
MesXslApprovalRecord record = findLatestDingRecord(bizTable, bizDataId);
if (record == null || oConvertUtils.isEmpty(record.getExternalInstanceId())) {
throw new IllegalArgumentException("未找到绑定的钉钉审批实例,请确认该单据已通过钉钉发起审批");
}
instanceId = record.getExternalInstanceId();
if (oConvertUtils.isEmpty(bizTable)) {
bizTable = record.getBizTable();
}
if (oConvertUtils.isEmpty(bizDataId)) {
bizDataId = record.getBizDataId();
}
}
JSONObject instance = dingTalkWorkflowService.getProcessInstance(instanceId);
if (instance == null) {
throw new IllegalStateException("拉取钉钉审批实例详情失败,请稍后重试");
}
DingProcessInstanceFlowVO vo = new DingProcessInstanceFlowVO();
vo.setProcessInstanceId(instanceId);
vo.setTitle(instance.getString("title"));
vo.setStatus(instance.getString("status"));
vo.setResult(instance.getString("result"));
vo.setBizTable(bizTable);
vo.setBizDataId(bizDataId);
List<DingOperationRecordVO> operationRecords = parseOperationRecords(instance.getJSONArray("operationRecords"));
//update-begin---author:GHT ---date:20260608 for【审批注册中心】钉钉操作人ID映射本地用户姓名-----------
enrichOperatorNames(operationRecords);
//update-end---author:GHT ---date:20260608 for【审批注册中心】钉钉操作人ID映射本地用户姓名-----------
vo.setOperationRecords(operationRecords);
return vo;
}
private MesXslApprovalRecord findLatestDingRecord(String bizTable, String bizDataId) {
if (oConvertUtils.isEmpty(bizTable) || oConvertUtils.isEmpty(bizDataId)) {
return null;
}
return approvalRecordService.lambdaQuery()
.eq(MesXslApprovalRecord::getBizTable, bizTable)
.eq(MesXslApprovalRecord::getBizDataId, bizDataId)
.eq(MesXslApprovalRecord::getChannel, ApprovalRecordConstants.CHANNEL_DINGTALK)
.orderByDesc(MesXslApprovalRecord::getLaunchNo)
.orderByDesc(MesXslApprovalRecord::getApplyTime)
.last("LIMIT 1")
.one();
}
private List<DingOperationRecordVO> parseOperationRecords(JSONArray records) {
List<DingOperationRecordVO> list = new ArrayList<>();
if (records == null || records.isEmpty()) {
return list;
}
for (int i = 0; i < records.size(); i++) {
JSONObject rec = records.getJSONObject(i);
if (rec == null) {
continue;
}
DingOperationRecordVO item = new DingOperationRecordVO();
item.setUserId(rec.getString("userId"));
item.setDate(rec.getString("date"));
item.setType(rec.getString("type"));
item.setResult(rec.getString("result"));
item.setRemark(rec.getString("remark"));
item.setShowName(rec.getString("showName"));
item.setActivityId(rec.getString("activityId"));
JSONArray ccUserIds = rec.getJSONArray("ccUserIds");
if (ccUserIds != null && !ccUserIds.isEmpty()) {
List<String> ccList = new ArrayList<>();
for (int j = 0; j < ccUserIds.size(); j++) {
String cc = ccUserIds.getString(j);
if (oConvertUtils.isNotEmpty(cc)) {
ccList.add(cc);
}
}
item.setCcUserIds(ccList);
}
JSONArray images = rec.getJSONArray("images");
if (images != null && !images.isEmpty()) {
List<String> imageList = new ArrayList<>();
for (int j = 0; j < images.size(); j++) {
String img = images.getString(j);
if (oConvertUtils.isNotEmpty(img)) {
imageList.add(img);
}
}
item.setImages(imageList);
}
list.add(item);
}
list.sort(Comparator.comparing(DingOperationRecordVO::getDate, Comparator.nullsLast(String::compareTo)));
return list;
}
//update-begin---author:GHT ---date:20260608 for【审批注册中心】钉钉操作人ID映射本地用户姓名-----------
/**
* 将 operationRecords 中的钉钉 userId 映射为本地用户姓名。
* 查询链sys_user.ding_user_id → sys_third_account.third_user_id → 保留原钉钉ID。
*/
private void enrichOperatorNames(List<DingOperationRecordVO> list) {
if (list == null || list.isEmpty()) {
return;
}
Set<String> dtUserIds = new HashSet<>();
for (DingOperationRecordVO item : list) {
if (item == null) {
continue;
}
if (oConvertUtils.isNotEmpty(item.getUserId())) {
dtUserIds.add(item.getUserId());
}
if (item.getCcUserIds() != null) {
for (String cc : item.getCcUserIds()) {
if (oConvertUtils.isNotEmpty(cc)) {
dtUserIds.add(cc);
}
}
}
}
Map<String, String> nameMap = batchResolveDtUserDisplayNames(dtUserIds);
for (DingOperationRecordVO item : list) {
if (item == null) {
continue;
}
if (oConvertUtils.isNotEmpty(item.getUserId())) {
item.setUserName(nameMap.getOrDefault(item.getUserId(), item.getUserId()));
}
if (isUnknownShowName(item.getShowName())) {
item.setShowName(null);
}
}
}
private Map<String, String> batchResolveDtUserDisplayNames(Collection<String> dtUserIds) {
Map<String, String> result = new HashMap<>();
if (dtUserIds == null || dtUserIds.isEmpty()) {
return result;
}
List<String> ids = dtUserIds.stream().filter(oConvertUtils::isNotEmpty).distinct().collect(Collectors.toList());
if (ids.isEmpty()) {
return result;
}
String inClause = ids.stream().map(id -> "?").collect(Collectors.joining(","));
try {
List<Map<String, Object>> localRows = jdbcTemplate.queryForList(
"SELECT ding_user_id, realname, username FROM sys_user "
+ "WHERE ding_user_id IN (" + inClause + ") AND (del_flag=0 OR del_flag IS NULL)",
ids.toArray());
for (Map<String, Object> row : localRows) {
String dtId = stringValue(row.get("ding_user_id"));
if (oConvertUtils.isEmpty(dtId)) {
continue;
}
result.put(dtId, pickDisplayName(stringValue(row.get("realname")), stringValue(row.get("username")), dtId));
}
} catch (Exception ignored) {
// 查询失败时降级保留钉钉ID
}
List<String> missing = ids.stream().filter(id -> !result.containsKey(id)).collect(Collectors.toList());
if (!missing.isEmpty()) {
String missingIn = missing.stream().map(id -> "?").collect(Collectors.joining(","));
try {
List<Map<String, Object>> thirdRows = jdbcTemplate.queryForList(
"SELECT t.third_user_id, u.realname, u.username "
+ "FROM sys_third_account t "
+ "JOIN sys_user u ON u.id = t.sys_user_id "
+ "WHERE t.third_type='dingtalk' AND t.third_user_id IN (" + missingIn + ") "
+ "AND (t.del_flag=0 OR t.del_flag IS NULL) AND (u.del_flag=0 OR u.del_flag IS NULL)",
missing.toArray());
for (Map<String, Object> row : thirdRows) {
String dtId = stringValue(row.get("third_user_id"));
if (oConvertUtils.isEmpty(dtId) || result.containsKey(dtId)) {
continue;
}
result.put(dtId, pickDisplayName(stringValue(row.get("realname")), stringValue(row.get("username")), dtId));
}
} catch (Exception ignored) {
// 查询失败时降级保留钉钉ID
}
}
for (String id : ids) {
result.putIfAbsent(id, id);
}
return result;
}
private String pickDisplayName(String realname, String username, String fallback) {
if (oConvertUtils.isNotEmpty(realname)) {
return realname;
}
if (oConvertUtils.isNotEmpty(username)) {
return username;
}
return fallback;
}
private boolean isUnknownShowName(String showName) {
if (oConvertUtils.isEmpty(showName)) {
return true;
}
return "UNKNOWN".equalsIgnoreCase(showName.trim());
}
private String stringValue(Object value) {
return value == null ? null : String.valueOf(value);
}
//update-end---author:GHT ---date:20260608 for【审批注册中心】钉钉操作人ID映射本地用户姓名-----------
private String buildBizKey(String bizTable, String bizDataId) {
return bizTable + "#" + bizDataId;
}
//update-end---author:GHT ---date:20260608 for【审批注册中心】查看钉钉审批流转记录-----------
//update-begin---author:GHT ---date:20260608 for【审批注册中心】审批节点改由实例tasks按activityId解析-----------
@Override
public DingProcessForecastVO getDingProcessForecast(String bizTable, String bizDataId, String processInstanceId) {
MesXslApprovalRecord dingRecord = null;
String instanceId = processInstanceId;
if (oConvertUtils.isEmpty(instanceId)) {
dingRecord = findLatestDingRecord(bizTable, bizDataId);
if (dingRecord == null || oConvertUtils.isEmpty(dingRecord.getExternalInstanceId())) {
throw new IllegalArgumentException("未找到绑定的钉钉审批实例,请确认该单据已通过钉钉发起审批");
}
instanceId = dingRecord.getExternalInstanceId();
} else {
dingRecord = approvalRecordService.lambdaQuery()
.eq(MesXslApprovalRecord::getExternalInstanceId, instanceId)
.eq(MesXslApprovalRecord::getChannel, ApprovalRecordConstants.CHANNEL_DINGTALK)
.orderByDesc(MesXslApprovalRecord::getLaunchNo)
.last("LIMIT 1")
.one();
if (dingRecord == null && oConvertUtils.isNotEmpty(bizTable) && oConvertUtils.isNotEmpty(bizDataId)) {
dingRecord = findLatestDingRecord(bizTable, bizDataId);
}
}
JSONObject instance = dingTalkWorkflowService.getProcessInstance(instanceId);
if (instance == null) {
throw new IllegalStateException("拉取钉钉审批实例详情失败,请稍后重试");
}
//update-begin---author:GHT ---date:20260608 for【审批注册中心】审批节点改由实例tasks按activityId解析-----------
MesXslApprovalFlow mesFlow = resolveMesFlow(dingRecord);
List<DingProcessForecastNodeVO> nodes = parseInstanceTaskNodes(instance, dingRecord, mesFlow);
//update-end---author:GHT ---date:20260608 for【审批注册中心】审批节点改由实例tasks按activityId解析-----------
DingProcessForecastVO vo = new DingProcessForecastVO();
vo.setProcessInstanceId(instanceId);
vo.setProcessCode(resolveProcessCode(dingRecord));
vo.setTemplateName(resolveTemplateName(dingRecord));
vo.setMesFlowName(mesFlow != null ? mesFlow.getFlowName() : (dingRecord != null ? dingRecord.getFlowName() : null));
vo.setNodeSource("审批实例tasks按activityId解析");
vo.setNodes(nodes);
return vo;
}
private MesXslApprovalFlow resolveMesFlow(MesXslApprovalRecord dingRecord) {
if (dingRecord == null || oConvertUtils.isEmpty(dingRecord.getFlowId())) {
return null;
}
return approvalFlowService.getById(dingRecord.getFlowId());
}
private String resolveProcessCode(MesXslApprovalRecord dingRecord) {
if (dingRecord == null || oConvertUtils.isEmpty(dingRecord.getTemplateId())) {
return null;
}
MesXslDingProcessTpl tpl = dingProcessTplService.getById(dingRecord.getTemplateId());
return tpl == null ? null : tpl.getProcessCode();
}
private String resolveTemplateName(MesXslApprovalRecord dingRecord) {
if (dingRecord == null) {
return null;
}
if (oConvertUtils.isNotEmpty(dingRecord.getTemplateName())) {
return dingRecord.getTemplateName();
}
if (oConvertUtils.isEmpty(dingRecord.getTemplateId())) {
return null;
}
MesXslDingProcessTpl tpl = dingProcessTplService.getById(dingRecord.getTemplateId());
return tpl == null ? null : tpl.getTplName();
}
//update-begin---author:GHT ---date:20260608 for【审批注册中心】审批节点改由实例tasks按activityId解析-----------
/**
* 从审批实例 tasks 按 activityId 分组解析实际审批节点,审批方式取自 MES 流程 multiMode。
*/
private List<DingProcessForecastNodeVO> parseInstanceTaskNodes(JSONObject instance,
MesXslApprovalRecord dingRecord,
MesXslApprovalFlow mesFlow) {
String flowConfig = mesFlow == null ? null : mesFlow.getFlowConfig();
if (oConvertUtils.isEmpty(flowConfig)) {
return new ArrayList<>();
}
List<NodePair> pairs = instanceStageExtractor.alignMesNodesWithTasks(instance, flowConfig);
if (pairs.isEmpty()) {
return new ArrayList<>();
}
Map<String, String> activityNameMap = buildActivityNameMap(instance);
List<DingProcessForecastNodeVO> nodes = new ArrayList<>();
for (NodePair pair : pairs) {
JSONObject mesNode = pair.getMesNode();
String activityId = pair.getActivityId();
List<JSONObject> taskList = pair.getTaskList();
String approvalMethod = instanceStageExtractor.resolveApprovalMethod(mesNode);
NodeTaskDecision decision = instanceStageExtractor.evaluateNodeTasks(taskList, approvalMethod);
String mesNodeName = mesNode == null ? null : mesNode.getString("name");
String activityName = firstNonEmpty(activityNameMap.get(activityId), mesNodeName, "审批节点" + pair.getStepNo());
List<String> actionerIds = decision.getActorUserIds() == null ? new ArrayList<>() : decision.getActorUserIds();
List<String> actionerNames = instanceStageExtractor.resolveActorNames(actionerIds);
DingProcessForecastNodeVO node = new DingProcessForecastNodeVO();
node.setStepNo(pair.getStepNo());
node.setActivityId(activityId);
node.setActivityName(activityName);
node.setMesNodeName(mesNodeName);
node.setActivityType("target_approval");
node.setApprovalMethod(approvalMethod);
node.setApprovalMethodText(instanceStageExtractor.approvalMethodText(approvalMethod));
node.setActionerUserIds(actionerIds);
node.setActionerNames(actionerNames);
node.setNodeStatus(decision.getNodeStatus());
node.setNodeStatusText(decision.getNodeStatusText());
nodes.add(node);
}
return nodes;
}
private Map<String, String> buildActivityNameMap(JSONObject instance) {
Map<String, String> map = new HashMap<>();
JSONArray records = instance.getJSONArray("operationRecords");
if (records == null || records.isEmpty()) {
return map;
}
for (int i = 0; i < records.size(); i++) {
JSONObject rec = records.getJSONObject(i);
if (rec == null) {
continue;
}
String activityId = rec.getString("activityId");
String showName = rec.getString("showName");
if (oConvertUtils.isNotEmpty(activityId) && oConvertUtils.isNotEmpty(showName)
&& !isUnknownShowName(showName) && !map.containsKey(activityId)) {
map.put(activityId, showName);
}
}
return map;
}
private String firstNonEmpty(String... values) {
if (values == null) {
return null;
}
for (String value : values) {
if (oConvertUtils.isNotEmpty(value)) {
return value.trim();
}
}
return null;
}
//update-end---author:GHT ---date:20260608 for【审批注册中心】审批节点改由实例tasks按activityId解析-----------
//update-begin---author:GHT ---date:20260608 for【审批注册中心】查看钉钉审批实例原始JSON-----------
@Override
public JSONObject getDingProcessInstance(String bizTable, String bizDataId, String processInstanceId) {
String instanceId = processInstanceId;
if (oConvertUtils.isEmpty(instanceId)) {
MesXslApprovalRecord record = findLatestDingRecord(bizTable, bizDataId);
if (record == null || oConvertUtils.isEmpty(record.getExternalInstanceId())) {
throw new IllegalArgumentException("未找到绑定的钉钉审批实例,请确认该单据已通过钉钉发起审批");
}
instanceId = record.getExternalInstanceId();
}
JSONObject raw = dingTalkWorkflowService.getProcessInstanceRaw(instanceId);
if (raw == null) {
throw new IllegalStateException("拉取钉钉审批实例详情失败,请稍后重试");
}
return raw;
}
//update-end---author:GHT ---date:20260608 for【审批注册中心】查看钉钉审批实例原始JSON-----------
}

View File

@@ -0,0 +1,49 @@
package org.jeecg.modules.xslmes.approval.integration.vo;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import lombok.experimental.Accessors;
import java.io.Serializable;
import java.util.List;
/**
* 钉钉审批实例操作记录(时间轴展示)
*/
@Data
@Accessors(chain = true)
@Schema(description = "钉钉审批操作记录")
public class DingOperationRecordVO implements Serializable {
private static final long serialVersionUID = 1L;
@Schema(description = "操作人钉钉userId")
private String userId;
@Schema(description = "操作人姓名(本地用户映射)")
private String userName;
@Schema(description = "操作时间(ISO8601)")
private String date;
@Schema(description = "操作类型")
private String type;
@Schema(description = "操作结果")
private String result;
@Schema(description = "备注/意见")
private String remark;
@Schema(description = "节点展示名称")
private String showName;
@Schema(description = "节点活动ID")
private String activityId;
@Schema(description = "抄送人列表")
private List<String> ccUserIds;
@Schema(description = "图片URL列表")
private List<String> images;
}

View File

@@ -0,0 +1,52 @@
package org.jeecg.modules.xslmes.approval.integration.vo;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import lombok.experimental.Accessors;
import java.io.Serializable;
import java.util.List;
/**
* 钉钉 processForecast 预测审批节点
*/
@Data
@Accessors(chain = true)
@Schema(description = "钉钉预测审批节点")
public class DingProcessForecastNodeVO implements Serializable {
private static final long serialVersionUID = 1L;
@Schema(description = "节点序号(从1开始)")
private Integer stepNo;
@Schema(description = "节点活动ID")
private String activityId;
@Schema(description = "节点名称")
private String activityName;
@Schema(description = "MES审批流节点名称")
private String mesNodeName;
@Schema(description = "节点类型(如target_select/target_approval)")
private String activityType;
@Schema(description = "审批方式 NONE/AND/OR/ONE_BY_ONE")
private String approvalMethod;
@Schema(description = "审批方式中文")
private String approvalMethodText;
@Schema(description = "审批人钉钉userId列表")
private List<String> actionerUserIds;
@Schema(description = "审批人姓名列表(本地映射)")
private List<String> actionerNames;
@Schema(description = "节点状态(聚合tasks)")
private String nodeStatus;
@Schema(description = "节点状态中文")
private String nodeStatusText;
}

View File

@@ -0,0 +1,37 @@
package org.jeecg.modules.xslmes.approval.integration.vo;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import lombok.experimental.Accessors;
import java.io.Serializable;
import java.util.List;
/**
* 钉钉审批实例 tasks 解析的审批节点结果
*/
@Data
@Accessors(chain = true)
@Schema(description = "钉钉审批节点(实例tasks解析)")
public class DingProcessForecastVO implements Serializable {
private static final long serialVersionUID = 1L;
@Schema(description = "钉钉审批实例ID")
private String processInstanceId;
@Schema(description = "钉钉processCode")
private String processCode;
@Schema(description = "钉钉模板名称")
private String templateName;
@Schema(description = "MES审批流名称")
private String mesFlowName;
@Schema(description = "节点来源说明")
private String nodeSource;
@Schema(description = "审批节点列表")
private List<DingProcessForecastNodeVO> nodes;
}

View File

@@ -0,0 +1,40 @@
package org.jeecg.modules.xslmes.approval.integration.vo;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import lombok.experimental.Accessors;
import java.io.Serializable;
import java.util.List;
/**
* 钉钉审批实例流转详情(供前端时间轴展示)
*/
@Data
@Accessors(chain = true)
@Schema(description = "钉钉审批实例流转详情")
public class DingProcessInstanceFlowVO implements Serializable {
private static final long serialVersionUID = 1L;
@Schema(description = "钉钉审批实例ID")
private String processInstanceId;
@Schema(description = "审批标题")
private String title;
@Schema(description = "实例状态 RUNNING/COMPLETED/TERMINATED 等")
private String status;
@Schema(description = "审批结果 agree/refuse 等")
private String result;
@Schema(description = "业务单据ID")
private String bizDataId;
@Schema(description = "业务表名")
private String bizTable;
@Schema(description = "操作记录列表(时间轴)")
private List<DingOperationRecordVO> operationRecords;
}

View File

@@ -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<MesXslDingPr
return Result.error("钉钉 AccessToken 获取失败,请检查[系统配置-第三方应用]中的钉钉配置");
}
// ② 获取当前用户的钉钉 userId优先从 sys_third_account 查,其次用手机号降级)
String dtUserId = null;
//update-begin---author:GHT ---date:2026-06-08 for【XSLMES-20260608】优先查本地ding_user_id降级sys_third_account最终降级手机号API-----------
// ② 获取当前用户的钉钉 userId三级降级ding_user_id → sys_third_account → phone→API
int tenantId = oConvertUtils.getInt(TenantContext.getTenant(), CommonConstant.TENANT_ID_DEFAULT_VALUE);
List<SysThirdAccount> accounts = sysThirdAccountService.listThirdUserIdByUsername(
new String[]{loginUser.getUsername()}, "dingtalk", tenantId);
if (accounts != null && !accounts.isEmpty() && oConvertUtils.isNotEmpty(accounts.get(0).getThirdUserId())) {
dtUserId = accounts.get(0).getThirdUserId();
} else if (oConvertUtils.isNotEmpty(loginUser.getPhone())) {
Response<String> resp = JdtUserAPI.getUseridByMobile(loginUser.getPhone(), accessToken);
if (resp.isSuccess() && oConvertUtils.isNotEmpty(resp.getResult())) {
dtUserId = resp.getResult();
}
}
String dtUserId = resolveDtUserId(loginUser.getUsername(), loginUser.getPhone(), accessToken, tenantId);
//update-end---author:GHT ---date:2026-06-08 for【XSLMES-20260608】优先查本地ding_user_id降级sys_third_account最终降级手机号API-----------
if (oConvertUtils.isEmpty(dtUserId)) {
return Result.error("未能获取当前用户的钉钉 userId请先完成钉钉账号绑定或确认手机号已在企业钉钉中注册");
}
@@ -1240,6 +1233,19 @@ public class MesXslDingProcessTplController extends JeecgController<MesXslDingPr
}
//update-end---author:GHT ---date:2026-06-04 for【MESToDing审批配置】抄送人节点映射钉钉ccList-----------
//update-begin---author:GHT ---date:20260605 for【XSLMES-20260605-K8R4】发起前调用processForecast构建节点序号映射-----------
String nodeActivityMapJson = null;
try {
JSONArray forecastRules = callProcessForecast(accessToken, reqBody);
nodeActivityMapJson = buildNodeActivityMapJson(forecastRules);
if (oConvertUtils.isNotEmpty(nodeActivityMapJson)) {
log.info("【钉钉发起审批】nodeActivityMap构建成功 entries={}", JSONArray.parseArray(nodeActivityMapJson).size());
}
} catch (Exception e) {
log.warn("【钉钉发起审批】processForecast失败将使用索引映射降级: {}", e.getMessage());
}
//update-end---author:GHT ---date:20260605 for【XSLMES-20260605-K8R4】发起前调用processForecast构建节点序号映射-----------
try {
String reqJson = reqBody.toJSONString();
log.info("【钉钉发起审批】processCode={} flowId={}\n请求体(formComponentValues共{}项, approvers共{}步, ccList共{}人):\n{}",
@@ -1256,12 +1262,18 @@ public class MesXslDingProcessTplController extends JeecgController<MesXslDingPr
String dingInstanceId = resp.getString("instanceId");
//update-begin---author:GHT ---date:20260604 for【QH-MES审批台账】钉钉发起成功后写入审批台账-----
//update-begin---author:GHT ---date:20260605 for【XSLMES-20260605-K8R4】台账写入nodeActivityMap节点映射-----------
if (oConvertUtils.isNotEmpty(bizTable) && oConvertUtils.isNotEmpty(bizDataId)) {
approvalGateService.createRunningRecord(
approvalGateService.buildDingDraft(bizTable, approvalFlow.getBizTableName(), bizCode, bizDataId,
bizTitle, flowId, approvalFlow.getFlowName(), tpl.getId(), tpl.getTplName(),
dingInstanceId, loginUser, tenantId));
MesXslApprovalRecord dingRecord = approvalGateService.buildDingDraft(
bizTable, approvalFlow.getBizTableName(), bizCode, bizDataId,
bizTitle, flowId, approvalFlow.getFlowName(), tpl.getId(), tpl.getTplName(),
dingInstanceId, loginUser, tenantId);
if (oConvertUtils.isNotEmpty(nodeActivityMapJson)) {
dingRecord.setNodeActivityMap(nodeActivityMapJson);
}
approvalGateService.createRunningRecord(dingRecord);
}
//update-end---author:GHT ---date:20260605 for【XSLMES-20260605-K8R4】台账写入nodeActivityMap节点映射-----------
//update-end---author:GHT ---date:20260604 for【QH-MES审批台账】钉钉发起成功后写入审批台账-----
Map<String, Object> result = new LinkedHashMap<>();
@@ -1567,43 +1579,33 @@ public class MesXslDingProcessTplController extends JeecgController<MesXslDingPr
}
//update-end---author:GHT ---date:2026-06-04 for【MESToDing审批配置】抄送人节点映射钉钉ccList-----------
//update-begin---author:GHT ---date:2026-06-08 for【XSLMES-20260608】resolveDtUserIdWithFallback简化手机号统一由resolveDtUserId三级降级处理-----------
/**
* 按用户名解析钉钉 userId带本次调用级缓存。
* 解析顺序:
* ① sys_third_account已完成钉钉账号绑定
* ② sys_user.phone → JdtUserAPI.getUseridByMobile手机号已在企业钉钉注册
* 实际解析由 {@link #resolveDtUserId} 完成三级降级ding_user_id → sys_third_account → phone→API
*/
private String resolveDtUserIdWithFallback(String username, String accessToken, int tenantId,
Map<String, String> cache) {
if (cache.containsKey(username)) {
return cache.get(username);
}
// sys_third_account
String dtId = resolveDtUserId(username, null, accessToken, tenantId);
if (oConvertUtils.isNotEmpty(dtId)) {
cache.put(username, dtId);
return dtId;
}
// ② 从 sys_user 取手机号再查
// 预先从 sys_user 取手机号,一并传给 resolveDtUserId 作为最终降级依据
String phone = null;
try {
List<String> phones = jdbcTemplate.queryForList(
"SELECT phone FROM sys_user WHERE username = ? AND (del_flag = 0 OR del_flag IS NULL) LIMIT 1",
"SELECT phone FROM sys_user WHERE username=? AND (del_flag=0 OR del_flag IS NULL) LIMIT 1",
String.class, username);
if (!phones.isEmpty() && oConvertUtils.isNotEmpty(phones.get(0))) {
String phone = phones.get(0).trim();
Response<String> resp = JdtUserAPI.getUseridByMobile(phone, accessToken);
if (resp.isSuccess() && oConvertUtils.isNotEmpty(resp.getResult())) {
dtId = resp.getResult();
cache.put(username, dtId);
return dtId;
}
phone = phones.get(0).trim();
}
} catch (Exception e) {
log.warn("查询用户 {} 手机号失败: {}", username, e.getMessage());
log.warn("[resolveDtUserIdWithFallback] 查询手机号失败 username={}: {}", username, e.getMessage());
}
cache.put(username, null);
return null;
String dtId = resolveDtUserId(username, phone, accessToken, tenantId);
cache.put(username, dtId);
return dtId;
}
//update-end---author:GHT ---date:2026-06-08 for【XSLMES-20260608】resolveDtUserIdWithFallback简化手机号统一由resolveDtUserId三级降级处理-----------
//update-end---author:GHT ---date:2026-06-04 for【MESToDing审批配置】审批流解析审批人-兜底用sys_user手机号查钉钉-----------
/** 深度遍历节点树,收集所有 type=approver 的节点(包含条件分支路径上的节点) */
@@ -1677,25 +1679,47 @@ public class MesXslDingProcessTplController extends JeecgController<MesXslDingPr
}
//update-end---author:GHT ---date:2026-06-03 for【MESToDing审批配置】通过userId查用户所属部门ID-----------
//update-begin---author:GHT ---date:2026-06-08 for【XSLMES-20260608】resolveDtUserId优先查本地ding_user_id减少DingTalk API调用-----------
/**
* 解析用户的钉钉 userId优先从 sys_third_account 查已绑定的,其次用手机号降级查询。
* 解析用户的钉钉 userId,三级降级
* ① sys_user.ding_user_id本地字段无需 API最快
* ② sys_third_account.third_user_id第三方账号绑定表
* ③ phone → JdtUserAPI.getUseridByMobile最终降级发起网络调用
*/
private String resolveDtUserId(String username, String phone, String accessToken, int tenantId) {
if (oConvertUtils.isNotEmpty(username)) {
// ① sys_user.ding_user_id
try {
List<String> dingIds = jdbcTemplate.queryForList(
"SELECT ding_user_id FROM sys_user WHERE username=? AND (del_flag=0 OR del_flag IS NULL)"
+ " AND ding_user_id IS NOT NULL AND ding_user_id!='' LIMIT 1",
String.class, username);
if (!dingIds.isEmpty() && oConvertUtils.isNotEmpty(dingIds.get(0))) {
log.info("[resolveDtUserId] ding_user_id命中 username={} dtId={}", username, dingIds.get(0));
return dingIds.get(0);
}
} catch (Exception e) {
log.warn("[resolveDtUserId] 查询ding_user_id异常 username={}: {}", username, e.getMessage());
}
// ② sys_third_account
List<SysThirdAccount> accounts = sysThirdAccountService.listThirdUserIdByUsername(
new String[]{username}, "dingtalk", tenantId);
if (accounts != null && !accounts.isEmpty() && oConvertUtils.isNotEmpty(accounts.get(0).getThirdUserId())) {
log.info("[resolveDtUserId] sys_third_account命中 username={} dtId={}", username, accounts.get(0).getThirdUserId());
return accounts.get(0).getThirdUserId();
}
}
// ③ phone → DingTalk API最终降级
if (oConvertUtils.isNotEmpty(phone)) {
Response<String> resp = JdtUserAPI.getUseridByMobile(phone, accessToken);
if (resp.isSuccess() && oConvertUtils.isNotEmpty(resp.getResult())) {
log.info("[resolveDtUserId] 手机号降级成功 phone={} dtId={}", phone, resp.getResult());
return resp.getResult();
}
}
return null;
}
//update-end---author:GHT ---date:2026-06-08 for【XSLMES-20260608】resolveDtUserId优先查本地ding_user_id减少DingTalk API调用-----------
//update-begin---author:GHT ---date:20260604 for【QH-MES审批台账】钉钉发起参数解析辅助-----
private String str(Object value) {
@@ -1709,5 +1733,68 @@ public class MesXslDingProcessTplController extends JeecgController<MesXslDingPr
return oConvertUtils.isNotEmpty(second) ? second.trim() : "";
}
//update-end---author:GHT ---date:20260604 for【QH-MES审批台账】钉钉发起参数解析辅助-----
//update-begin---author:GHT ---date:20260605 for【XSLMES-20260605-K8R4】processForecast节点映射辅助方法-----------
/**
* 调用钉钉 processForecast 接口预测审批流节点信息。
* 使用与 createProcessInstance 相同的请求体,不会实际发起审批。
*
* @return workflowActivityRules 数组(每项含 approvalMethod/activityActioners失败返回 null
*/
private JSONArray callProcessForecast(String accessToken, JSONObject processReqBody) {
try {
String respBody = callDingApi("POST", "https://api.dingtalk.com/v1.0/workflow/process/forecast",
accessToken, processReqBody.toJSONString());
JSONObject resp = JSONObject.parseObject(respBody);
if (resp.containsKey("code")) {
log.warn("【钉钉processForecast】返回错误 code={} msg={}", resp.getString("code"), resp.getString("message"));
return null;
}
JSONObject result = resp.getJSONObject("result");
return result != null ? result.getJSONArray("workflowActivityRules") : null;
} catch (Exception e) {
log.warn("【钉钉processForecast】调用异常: {}", e.getMessage());
return null;
}
}
/**
* 将 processForecast 返回的 workflowActivityRules 转换为节点序号映射 JSON 数组。
* 每项含approvalMethod, totalActioners, completionAt累计任务数边界
* <p>
* completionAt 计算规则:
* - AND / ONE_BY_ONE节点需要所有审批人完成effectiveOps = totalActioners
* - OR / NONE 或单人首位通过即完成effectiveOps = 1
* <p>
* 在 bpms_task_change 回调中,当 taskOps.size() == completionAt 时表示该节点刚完成。
*/
private String buildNodeActivityMapJson(JSONArray forecastRules) {
if (forecastRules == null || forecastRules.isEmpty()) return null;
try {
JSONArray mapArr = new JSONArray();
int cumulative = 0;
for (int i = 0; i < forecastRules.size(); i++) {
JSONObject rule = forecastRules.getJSONObject(i);
String approvalMethod = oConvertUtils.isNotEmpty(rule.getString("approvalMethod"))
? rule.getString("approvalMethod") : "NONE";
JSONArray actioners = rule.getJSONArray("activityActioners");
int totalActioners = (actioners != null && !actioners.isEmpty()) ? actioners.size() : 1;
boolean waitForAll = "AND".equalsIgnoreCase(approvalMethod)
|| "ONE_BY_ONE".equalsIgnoreCase(approvalMethod);
int effectiveOps = (waitForAll && totalActioners > 1) ? totalActioners : 1;
cumulative += effectiveOps;
JSONObject entry = new JSONObject();
entry.put("approvalMethod", approvalMethod);
entry.put("totalActioners", totalActioners);
entry.put("completionAt", cumulative);
mapArr.add(entry);
}
return mapArr.toJSONString();
} catch (Exception e) {
log.warn("【钉钉发起审批】构建nodeActivityMap失败: {}", e.getMessage());
return null;
}
}
//update-end---author:GHT ---date:20260605 for【XSLMES-20260605-K8R4】processForecast节点映射辅助方法-----------
//update-end---author:GHT ---date:2026-06-03 for【MESToDing审批配置】手动填表发起钉钉审批-----------
}

View File

@@ -0,0 +1,272 @@
package org.jeecg.modules.xslmes.dingtalk.service;
import com.alibaba.fastjson2.JSONArray;
import com.alibaba.fastjson2.JSONObject;
import com.jeecg.dingtalk.api.core.response.Response;
import com.jeecg.dingtalk.api.user.JdtUserAPI;
import lombok.extern.slf4j.Slf4j;
import org.jeecg.common.util.oConvertUtils;
import org.jeecg.modules.system.entity.SysThirdAccount;
import org.jeecg.modules.system.service.ISysThirdAccountService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Service;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
/**
* 按 MES 审批流配置构建钉钉发起/预测审批请求体(含 approvers
* 与 {@link org.jeecg.modules.xslmes.dingtalk.controller.MesXslDingProcessTplController} 发起逻辑保持一致。
*/
@Slf4j
@Service
public class DingApprovalLaunchParamBuilder {
@Autowired
private JdbcTemplate jdbcTemplate;
@Autowired
private ISysThirdAccountService sysThirdAccountService;
//update-begin---author:GHT ---date:20260608 for【审批注册中心】按MES发起参数构建processForecast请求体-----------
/**
* 构建与 MES 发起钉钉审批时一致的 processForecast 请求体。
*/
public JSONObject buildForecastRequest(String processCode, String flowConfig, JSONObject instance,
String accessToken, Integer tenantId) {
JSONObject req = new JSONObject();
req.put("processCode", processCode);
String originatorUserId = instance.getString("originatorUserId");
if (oConvertUtils.isNotEmpty(originatorUserId)) {
req.put("originatorUserId", originatorUserId);
req.put("userId", originatorUserId);
}
String deptIdStr = instance.getString("originatorDeptId");
if (oConvertUtils.isNotEmpty(deptIdStr)) {
try {
req.put("deptId", Long.parseLong(deptIdStr));
} catch (NumberFormatException e) {
req.put("deptId", deptIdStr);
}
}
JSONArray formValues = instance.getJSONArray("formComponentValues");
if (formValues != null && !formValues.isEmpty()) {
req.put("formComponentValues", formValues);
}
if (oConvertUtils.isNotEmpty(flowConfig) && oConvertUtils.isNotEmpty(originatorUserId)) {
int tid = tenantId == null ? 0 : tenantId;
JSONArray approvers = buildApproversFromFlowConfig(flowConfig, originatorUserId, accessToken, tid);
if (!approvers.isEmpty()) {
req.put("approvers", approvers);
}
}
return req;
}
/**
* 按 MES 审批流 DFS 顺序提取审批节点名称(与 approvers 数组顺序对齐)。
*/
public List<String> listMesApproverNodeNames(String flowConfig) {
List<String> names = new ArrayList<>();
if (oConvertUtils.isEmpty(flowConfig)) {
return names;
}
try {
JSONObject root = JSONObject.parseObject(flowConfig);
List<JSONObject> approverNodes = new ArrayList<>();
collectApproverNodes(root, approverNodes);
LinkedHashSet<String> visited = new LinkedHashSet<>();
for (JSONObject node : approverNodes) {
String nid = node.getString("id");
if (nid != null && !visited.add(nid)) {
continue;
}
JSONObject props = node.getJSONObject("props");
if (props == null) {
continue;
}
String approverType = props.getString("approverType");
if ("leader".equals(approverType) || "field".equals(approverType)) {
continue;
}
String nodeName = node.getString("name");
names.add(oConvertUtils.isNotEmpty(nodeName) ? nodeName : "审批节点");
}
} catch (Exception e) {
log.warn("解析MES审批流节点名称失败: {}", e.getMessage());
}
return names;
}
public JSONArray buildApproversFromFlowConfig(String flowConfig, String originatorDtUserId,
String accessToken, int tenantId) {
JSONArray approvers = new JSONArray();
Map<String, String> dtIdCache = new HashMap<>();
try {
JSONObject root = JSONObject.parseObject(flowConfig);
List<JSONObject> approverNodes = new ArrayList<>();
collectApproverNodes(root, approverNodes);
LinkedHashSet<String> visitedNodeIds = new LinkedHashSet<>();
List<JSONObject> dedupedNodes = new ArrayList<>();
for (JSONObject n : approverNodes) {
String nid = n.getString("id");
if (nid == null || visitedNodeIds.add(nid)) {
dedupedNodes.add(n);
}
}
for (JSONObject node : dedupedNodes) {
JSONObject props = node.getJSONObject("props");
if (props == null) {
continue;
}
String approverType = props.getString("approverType");
String multiMode = props.getString("multiMode");
String actionType;
boolean isSingleMode = "none".equals(multiMode);
if ("or".equals(multiMode)) {
actionType = "OR";
} else if (isSingleMode) {
actionType = "NONE";
} else {
actionType = "AND";
}
List<String> dtUserIds = new ArrayList<>();
if ("user".equals(approverType)) {
String userText = props.getString("userText");
if (oConvertUtils.isNotEmpty(userText)) {
for (String username : userText.split("[,\\s]+")) {
username = username.trim();
if (oConvertUtils.isEmpty(username)) {
continue;
}
String dtId = resolveDtUserIdWithFallback(username, accessToken, tenantId, dtIdCache);
if (oConvertUtils.isNotEmpty(dtId)) {
dtUserIds.add(dtId);
}
}
}
} else if ("self".equals(approverType)) {
dtUserIds.add(originatorDtUserId);
} else if ("role".equals(approverType)) {
JSONArray roleList = props.getJSONArray("roleList");
if (roleList != null && !roleList.isEmpty()) {
List<String> roleIds = new ArrayList<>();
for (int ri = 0; ri < roleList.size(); ri++) {
String rid = roleList.getString(ri);
if (oConvertUtils.isNotEmpty(rid)) {
roleIds.add(rid);
}
}
if (!roleIds.isEmpty()) {
String inClause = String.join(",", Collections.nCopies(roleIds.size(), "?"));
List<String> usernames = jdbcTemplate.queryForList(
"SELECT DISTINCT u.username FROM sys_user u"
+ " INNER JOIN sys_user_role sur ON sur.user_id = u.id"
+ " WHERE sur.role_id IN (" + inClause + ")"
+ " AND (u.del_flag = 0 OR u.del_flag IS NULL)",
String.class, roleIds.toArray());
for (String username : usernames) {
String dtId = resolveDtUserIdWithFallback(username, accessToken, tenantId, dtIdCache);
if (oConvertUtils.isNotEmpty(dtId)) {
dtUserIds.add(dtId);
}
}
}
}
} else {
continue;
}
if (!dtUserIds.isEmpty()) {
List<String> unique = new ArrayList<>(new LinkedHashSet<>(dtUserIds));
if (isSingleMode && unique.size() > 1) {
unique = unique.subList(0, 1);
}
JSONObject step = new JSONObject();
step.put("actionType", actionType);
step.put("userIds", unique);
approvers.add(step);
}
}
} catch (Exception e) {
log.error("解析MES审批流approvers失败", e);
}
return approvers;
}
private void collectApproverNodes(JSONObject node, List<JSONObject> result) {
if (node == null) {
return;
}
if ("approver".equals(node.getString("type"))) {
result.add(node);
}
JSONObject child = node.getJSONObject("childNode");
if (child != null) {
collectApproverNodes(child, result);
}
JSONArray conditionNodes = node.getJSONArray("conditionNodes");
if (conditionNodes != null) {
for (int i = 0; i < conditionNodes.size(); i++) {
Object cn = conditionNodes.get(i);
if (cn instanceof JSONObject) {
collectApproverNodes((JSONObject) cn, result);
}
}
}
}
private String resolveDtUserIdWithFallback(String username, String accessToken, int tenantId,
Map<String, String> cache) {
if (cache.containsKey(username)) {
return cache.get(username);
}
String phone = null;
try {
List<String> phones = jdbcTemplate.queryForList(
"SELECT phone FROM sys_user WHERE username=? AND (del_flag=0 OR del_flag IS NULL) LIMIT 1",
String.class, username);
if (!phones.isEmpty() && oConvertUtils.isNotEmpty(phones.get(0))) {
phone = phones.get(0).trim();
}
} catch (Exception ignored) {
// 忽略
}
String dtId = resolveDtUserId(username, phone, accessToken, tenantId);
cache.put(username, dtId);
return dtId;
}
private String resolveDtUserId(String username, String phone, String accessToken, int tenantId) {
if (oConvertUtils.isNotEmpty(username)) {
try {
List<String> dingIds = jdbcTemplate.queryForList(
"SELECT ding_user_id FROM sys_user WHERE username=? AND (del_flag=0 OR del_flag IS NULL)"
+ " AND ding_user_id IS NOT NULL AND ding_user_id!='' LIMIT 1",
String.class, username);
if (!dingIds.isEmpty() && oConvertUtils.isNotEmpty(dingIds.get(0))) {
return dingIds.get(0);
}
} catch (Exception ignored) {
// 忽略
}
List<SysThirdAccount> accounts = sysThirdAccountService.listThirdUserIdByUsername(
new String[]{username}, "dingtalk", tenantId);
if (accounts != null && !accounts.isEmpty() && oConvertUtils.isNotEmpty(accounts.get(0).getThirdUserId())) {
return accounts.get(0).getThirdUserId();
}
}
if (oConvertUtils.isNotEmpty(phone)) {
Response<String> resp = JdtUserAPI.getUseridByMobile(phone, accessToken);
if (resp.isSuccess() && oConvertUtils.isNotEmpty(resp.getResult())) {
return resp.getResult();
}
}
return null;
}
//update-end---author:GHT ---date:20260608 for【审批注册中心】按MES发起参数构建processForecast请求体-----------
}

View File

@@ -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=RUNNING0行更新即终态已处理-----
//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<JSONObject> taskOps = workflowService.getTaskOperations(instance);
List<JSONObject> 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<JSONObject> loadApproverNodes(String flowId) {
List<JSONObject> 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<JSONObject> 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<JSONObject> out) {
if (node == null) return;
if ("approver".equals(node.getString("type"))) {
out.add(node);
}
JSONArray branches = node.getJSONArray("conditionNodes");
if (branches != null && !branches.isEmpty()) {
Object first = branches.get(0);
if (first instanceof JSONObject) {
collectApproverNodes(((JSONObject) first).getJSONObject("childNode"), out);
if (branches != null) {
for (int i = 0; i < branches.size(); i++) {
Object branch = branches.get(i);
if (branch instanceof JSONObject) {
collectAllApproverNodes(((JSONObject) branch).getJSONObject("childNode"), out);
}
}
}
collectApproverNodes(node.getJSONObject("childNode"), out);
collectAllApproverNodes(node.getJSONObject("childNode"), out);
}
/**
* 通过 activityId 在流程树中查找对应的审批节点(遍历所有分支)。
*
* @return 找到的节点;找不到返回 null
*/
private JSONObject findNodeByActivityId(String flowConfig, String activityId) {
if (oConvertUtils.isEmpty(flowConfig) || oConvertUtils.isEmpty(activityId)) return null;
try {
List<JSONObject> allNodes = new ArrayList<>();
collectAllApproverNodes(JSONObject.parseObject(flowConfig), allNodes);
for (JSONObject n : allNodes) {
if (activityId.equals(n.getString("id"))) {
return n;
}
}
} catch (Exception e) {
log.warn("{} findNodeByActivityId 失败 activityId={}: {}", LOG_TAG, activityId, e.getMessage());
}
return null;
}
/**
* 获取节点在 DFS 全分支遍历顺序中的稳定索引(用于 tryMarkNodeProcessed 幂等键)。
*
* @return nodeIndex≥0找不到返回 -1
*/
private int getNodeIndexByActivityId(String flowConfig, String activityId) {
if (oConvertUtils.isEmpty(flowConfig) || oConvertUtils.isEmpty(activityId)) return -1;
try {
List<JSONObject> allNodes = new ArrayList<>();
collectAllApproverNodes(JSONObject.parseObject(flowConfig), allNodes);
for (int i = 0; i < allNodes.size(); i++) {
if (activityId.equals(allNodes.get(i).getString("id"))) {
return i;
}
}
} catch (Exception e) {
log.warn("{} getNodeIndexByActivityId 失败 activityId={}: {}", LOG_TAG, activityId, e.getMessage());
}
return -1;
}
//update-end---author:GHT ---date:2026-06-08 for【缺陷修复-D2】collectAllApproverNodes遍历ALL分支替代只取conditionNodes[0]的旧方案-----------
private JSONObject findRefuseOp(List<JSONObject> taskOps) {
for (JSONObject op : taskOps) {
String r = op.getString("result");
@@ -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) {

View File

@@ -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。
* <p>
* 查询链sys_third_account(third_user_id=dtUserId) → sys_user_id → sys_user.username/password
* 查询链(三级降级):
* ① sys_user.ding_user_id = dtUserId本地字段无需 JOIN最快
* ② sys_third_account.third_user_id = dtUserId第三方账号绑定表
* ③ 均无命中 → admin 兜底
* <p>
* 这样回调业务接口时,接口内部通过 {@code SecurityUtils.getSubject().getPrincipal()}
* 拿到的就是真实审批人,而非 admin保证 proofread_by/audit_by/approve_by 字段写入正确。
@@ -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<String> localIds = jdbcTemplate.queryForList(
"SELECT id FROM sys_user WHERE ding_user_id=? AND (del_flag=0 OR del_flag IS NULL) LIMIT 1",
String.class, dtUserId);
if (!localIds.isEmpty() && oConvertUtils.isNotEmpty(localIds.get(0))) {
SysUser localUser = sysUserService.getById(localIds.get(0));
if (localUser != null && oConvertUtils.isNotEmpty(localUser.getPassword())) {
log.info("{} 生成操作人Token成功(ding_user_id) dtUserId={} mesUsername={}", LOG_TAG, dtUserId, localUser.getUsername());
return signAndCache(localUser.getUsername(), localUser.getPassword());
}
}
// ② 降级:查 sys_third_account第三方账号绑定表
List<String> userIds = jdbcTemplate.queryForList(
"SELECT sys_user_id FROM sys_third_account " +
"WHERE third_type='dingtalk' AND third_user_id=? AND (del_flag=0 OR del_flag IS NULL) LIMIT 1",
String.class, dtUserId);
//update-end---author:GHT ---date:2026-06-08 for【XSLMES-20260608】优先查sys_user.ding_user_id减少DingTalk API调用-----------
if (userIds.isEmpty() || oConvertUtils.isEmpty(userIds.get(0))) {
log.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。
* <p>
* bpms_task_change 事件本身不携带 activityId需通过此方法从拉取的实例详情中反查。
*
* @param instanceResult getProcessInstance 返回的 result 节点
* @param staffId bpms_task_change 事件中的 staffId当前审批人钉钉userId
* @return activityId找不到时返回 null
*/
public String resolveActivityIdForEvent(JSONObject instanceResult, String staffId) {
if (instanceResult == null || oConvertUtils.isEmpty(staffId)) {
log.info("{} resolveActivityIdForEvent 跳过instanceResult 或 staffId 为空", LOG_TAG);
return null;
}
JSONArray records = instanceResult.getJSONArray("operationRecords");
if (records == null || records.isEmpty()) {
log.info("{} resolveActivityIdForEvent 跳过operationRecords 为空", LOG_TAG);
return null;
}
// 取最后一条匹配的执行记录(多次审批同一节点时取最新)
String activityId = null;
for (int i = 0; i < records.size(); i++) {
JSONObject rec = records.getJSONObject(i);
if (rec == null) continue;
String type = rec.getString("type");
if (!"EXECUTE_TASK_NORMAL".equals(type) && !"EXECUTE_TASK_AGENT".equals(type)) continue;
if (staffId.equals(rec.getString("userId"))) {
String aid = rec.getString("activityId");
if (oConvertUtils.isNotEmpty(aid)) {
activityId = aid;
}
}
}
log.info("{} resolveActivityIdForEvent staffId={} activityId={}", LOG_TAG, staffId, activityId);
return activityId;
}
//update-begin---author:GHT ---date:2026-06-08 for【缺陷修复-D2/D3】DingTalk activityId 与 MES 节点 id 属独立 ID 体系,改用顺序位置映射-----------
/**
* 确定 activityId 在审批流程中的步骤序号0-based
* <p>
* DingTalk 为表单审批自动分配的 activityId 与 MES 设计时节点 id 属独立 ID 体系,无法直接匹配。
* 改用"activityId 在 operationRecords 中首次出现的顺序位置"作为步骤序号,
* 该位置与 MES 流程 DFS 节点顺序一一对应(发起时钉钉步骤 ↔ MES 节点按相同顺序排列)。
*
* @param instanceResult getProcessInstance 返回的 result 节点
* @param activityId 当前步骤的 DingTalk activityId
* @return 0-based 步骤序号;找不到时返回 -1
*/
public int resolveStepIndex(JSONObject instanceResult, String activityId) {
if (instanceResult == null || oConvertUtils.isEmpty(activityId)) return -1;
JSONArray records = instanceResult.getJSONArray("operationRecords");
if (records == null || records.isEmpty()) return -1;
List<String> distinctIds = new ArrayList<>();
for (int i = 0; i < records.size(); i++) {
JSONObject rec = records.getJSONObject(i);
if (rec == null) continue;
String type = rec.getString("type");
if (!"EXECUTE_TASK_NORMAL".equals(type) && !"EXECUTE_TASK_AGENT".equals(type)) continue;
String aid = rec.getString("activityId");
if (oConvertUtils.isNotEmpty(aid) && !distinctIds.contains(aid)) {
distinctIds.add(aid);
}
}
int idx = distinctIds.indexOf(activityId);
log.info("{} resolveStepIndex activityId={} distinctSequence={} stepIndex={}", LOG_TAG, activityId, distinctIds, idx);
return idx;
}
//update-end---author:GHT ---date:2026-06-08 for【缺陷修复-D2/D3】DingTalk activityId 与 MES 节点 id 属独立 ID 体系,改用顺序位置映射-----------
/**
* 检查某节点activityId是否已完成查看 tasks[] 中是否还有该节点的 RUNNING/NEW 任务。
* <p>
* 用于替代 completionAt 计数边界检测:会签/依次审批时,只要该 activityId 下还有
* RUNNING 或 NEW 状态任务,说明节点仍在进行中,不应触发节点回调。
*
* @param instanceResult getProcessInstance 返回的 result 节点
* @param activityId 要检查的节点活动ID
* @return true=节点已完成或找不到该节点的任务false=节点仍有待处理任务
*/
public boolean isNodeComplete(JSONObject instanceResult, String activityId) {
if (instanceResult == null || oConvertUtils.isEmpty(activityId)) {
return true;
}
JSONArray tasks = instanceResult.getJSONArray("tasks");
if (tasks == null || tasks.isEmpty()) {
return true;
}
for (int i = 0; i < tasks.size(); i++) {
JSONObject task = tasks.getJSONObject(i);
if (task == null) continue;
if (!activityId.equals(task.getString("activityId"))) continue;
String status = task.getString("status");
if ("RUNNING".equals(status) || "NEW".equals(status)) {
log.info("{} isNodeComplete=false activityId={} taskId={} status={}",
LOG_TAG, activityId, task.getLongValue("taskId"), status);
return false;
}
}
return true;
}
//update-begin---author:GHT ---date:20260608 for【钉钉Stream回调】会签节点完成判定增强刷新实例+multiMode兜底-----------
/**
* @deprecated 使用 {@link #resolveInstanceWhenNodeComplete(String, JSONObject, String, JSONObject, String)}
*/
@Deprecated
public JSONObject resolveInstanceWhenNodeComplete(String processInstanceId,
JSONObject instance,
String activityId,
JSONObject mesNode) {
return resolveInstanceWhenNodeComplete(processInstanceId, instance, activityId, mesNode, null);
}
//update-begin---author:GHT ---date:20260608 for【缺陷修复-会签集成】新增actionerDtUserId参数修复AND模式API延迟导致最后一个会签人任务仍RUNNING时节点误判未完成-----------
/**
* 会签/依次审批场景finish 事件到达时 tasks 可能短暂滞后,刷新实例后再判定;
* 仍不满足时用 MES 节点 multiMode 对同 activityId 的 tasks 做完成判定。
* <p>
* {@code actionerDtUserId}AND/ONE_BY_ONE 模式下,若仅该用户的任务仍为 RUNNINGAPI 延迟),
* 而其他参与人均已 AGREE则视节点为已完成避免竞态漏触发集成方案。
*
* @param actionerDtUserId 触发本次 bpms_task_change 的审批人钉钉 userId
* @return 节点已完成的最新实例快照;未完成返回 null
*/
public JSONObject resolveInstanceWhenNodeComplete(String processInstanceId,
JSONObject instance,
String activityId,
JSONObject mesNode,
String actionerDtUserId) {
if (isNodeCompleteForCallback(instance, activityId, mesNode, actionerDtUserId)) {
return instance;
}
JSONObject refreshed = getProcessInstance(processInstanceId);
if (refreshed != null && isNodeCompleteForCallback(refreshed, activityId, mesNode, actionerDtUserId)) {
log.info("{} 会签节点完成判定:刷新实例后通过 activityId={} instanceId={}",
LOG_TAG, activityId, processInstanceId);
return refreshed;
}
return null;
}
//update-end---author:GHT ---date:20260608 for【缺陷修复-会签集成】新增actionerDtUserId参数修复AND模式API延迟导致最后一个会签人任务仍RUNNING时节点误判未完成-----------
//update-begin---author:GHT ---date:20260608 for【缺陷修复-会签集成】重构节点完成判定以tasks全员COMPLETED+AGREE为主判定去除复杂mode链路-----------
/**
* 节点是否已完成(可触发集成方案):
* <ol>
* <li>主判定:同 activityId 下所有非取消任务均为 COMPLETED+AGREE会签全员通过 / 或签实际通过人已通过)</li>
* <li>API滞后兜底当前审批人任务仍 RUNNING其余均已 AGREEAND最后一人事件先于API更新到达</li>
* <li>或签/单人兜底非AND/ONE_BY_ONE模式时任意一人AGREE即完成其他人取消可能短暂延迟</li>
* </ol>
*/
private boolean isNodeCompleteForCallback(JSONObject instance, String activityId, JSONObject mesNode,
String actionerDtUserId) {
if (instance == null || oConvertUtils.isEmpty(activityId)) {
return false;
}
List<JSONObject> taskList = filterTasksByActivity(instance, activityId);
if (taskList.isEmpty()) {
return false;
}
// 1. 主判定:所有非取消任务均已 COMPLETED+AGREE
if (allNonCanceledTasksCompletedAgree(taskList)) {
log.info("{} 节点完成判定全员COMPLETED+AGREE activityId={} taskCount={}",
LOG_TAG, activityId, taskList.size());
return true;
}
// 2. API滞后兜底当前审批人任务仍RUNNING其余均已AGREE
if (oConvertUtils.isNotEmpty(actionerDtUserId) && isVirtuallyCompleteByActioner(taskList, actionerDtUserId)) {
log.info("{} 节点完成判定API滞后虚拟完成 activityId={} actionerDtUserId={}",
LOG_TAG, activityId, actionerDtUserId);
return true;
}
// 3. 或签/单人兜底任意一人AGREE即可其他人取消可能短暂延迟刷新实例后通常能命中主判定
if (mesNode != null && instanceStageExtractor != null) {
String method = instanceStageExtractor.resolveApprovalMethod(mesNode);
if (!"AND".equalsIgnoreCase(method) && !"ONE_BY_ONE".equalsIgnoreCase(method)) {
boolean anyAgree = taskList.stream().anyMatch(t -> t != null
&& "COMPLETED".equalsIgnoreCase(t.getString("status"))
&& "AGREE".equalsIgnoreCase(t.getString("result")));
if (anyAgree) {
log.info("{} 节点完成判定OR/NONE任一AGREE activityId={} method={}",
LOG_TAG, activityId, method);
return true;
}
}
}
return false;
}
/** 同 activityId 下所有非取消CANCELED任务是否均已 COMPLETED+AGREE */
private boolean allNonCanceledTasksCompletedAgree(List<JSONObject> taskList) {
int activeCount = 0;
int completedAgreeCount = 0;
for (JSONObject task : taskList) {
if (task == null) continue;
if ("CANCELED".equalsIgnoreCase(task.getString("status"))) continue;
activeCount++;
if ("COMPLETED".equalsIgnoreCase(task.getString("status"))
&& "AGREE".equalsIgnoreCase(task.getString("result"))) {
completedAgreeCount++;
}
}
return activeCount > 0 && completedAgreeCount == activeCount;
}
/**
* API滞后竞态补偿当前审批人任务仍 RUNNING其余均已 COMPLETED+AGREE视节点为已完成。
* 若存在其他人(非当前审批人)的 RUNNING/NEW 任务,立即返回 false节点确实未完成
*/
private boolean isVirtuallyCompleteByActioner(List<JSONObject> taskList, String actionerDtUserId) {
int activeCount = 0;
int agreeCount = 0;
int runningByActioner = 0;
for (JSONObject task : taskList) {
if (task == null) continue;
String status = task.getString("status");
if ("CANCELED".equalsIgnoreCase(status)) continue;
activeCount++;
String result = task.getString("result");
if ("COMPLETED".equalsIgnoreCase(status) && "AGREE".equalsIgnoreCase(result)) {
agreeCount++;
} else if ("RUNNING".equalsIgnoreCase(status) || "NEW".equalsIgnoreCase(status)) {
if (actionerDtUserId.equals(task.getString("userId"))) {
runningByActioner++;
} else {
return false;
}
}
}
return activeCount > 0 && runningByActioner > 0 && (agreeCount + runningByActioner) >= activeCount;
}
//update-end---author:GHT ---date:20260608 for【缺陷修复-会签集成】重构节点完成判定以tasks全员COMPLETED+AGREE为主判定去除复杂mode链路-----------
private List<JSONObject> filterTasksByActivity(JSONObject instance, String activityId) {
List<JSONObject> result = new ArrayList<>();
if (instance == null || oConvertUtils.isEmpty(activityId)) {
return result;
}
JSONArray tasks = instance.getJSONArray("tasks");
if (tasks == null) {
return result;
}
for (int i = 0; i < tasks.size(); i++) {
JSONObject task = tasks.getJSONObject(i);
if (task != null && activityId.equals(task.getString("activityId"))) {
result.add(task);
}
}
return result;
}
//update-end---author:GHT ---date:20260608 for【钉钉Stream回调】会签节点完成判定增强刷新实例+multiMode兜底-----------
/**
* 检查实例 operationRecords 中是否存在 REDIRECT_PROCESS审批退回记录。
* <p>
* 存在退回记录时nodeActivityMap 的 completionAt 计数已失效。
* 当前实现在检测到退回后记录警告日志;采用 activityId 方案时退回对节点定位无影响,
* 但对幂等性tryMarkNodeProcessed有影响退回后重新审批的节点会被幂等拦截。
*
* @return true=存在审批退回记录
*/
public boolean hasRedirectProcess(JSONObject instanceResult) {
if (instanceResult == null) return false;
JSONArray records = instanceResult.getJSONArray("operationRecords");
if (records == null) return false;
for (int i = 0; i < records.size(); i++) {
JSONObject rec = records.getJSONObject(i);
if (rec != null && "REDIRECT_PROCESS".equals(rec.getString("type"))) {
return true;
}
}
return false;
}
//update-end---author:GHT ---date:2026-06-08 for【缺陷修复-D2/D3/D4】基于activityId精准定位节点替代completionAt计数方案-----------
/** 生成 admin 系统 token用于钉钉用户未绑定 MES 账号时的兜底 */
public String generateAdminToken() {
try {

View File

@@ -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;

View File

@@ -289,6 +289,11 @@ public class MesXslMixingSpecServiceImpl extends ServiceImpl<MesXslMixingSpecMap
if (main.getDelFlag() == null) {
main.setDelFlag(0);
}
//update-begin---author:cursor ---date:20260608 for【XSLMES-20260608-A01】混炼示方新增默认编制状态-----------
if (StringUtils.isBlank(main.getStatus())) {
main.setStatus("compile");
}
//update-end---author:cursor ---date:20260608 for【XSLMES-20260608-A01】混炼示方新增默认编制状态-----------
}
private void clearChildren(String mainId) {

View File

@@ -56,6 +56,11 @@ import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.messaging.simp.SimpMessagingTemplate;
import org.springframework.web.bind.annotation.*;
//update-begin---author:GHT ---date:2026-06-08 for【XSLMES-20260608】同步钉钉ID功能-----------
import com.jeecg.dingtalk.api.core.response.Response;
import com.jeecg.dingtalk.api.user.JdtUserAPI;
import org.jeecg.modules.system.service.impl.ThirdAppDingtalkServiceImpl;
//update-end---author:GHT ---date:2026-06-08 for【XSLMES-20260608】同步钉钉ID功能-----------
import org.springframework.web.servlet.ModelAndView;
import jakarta.servlet.http.HttpServletRequest;
@@ -125,6 +130,11 @@ public class SysUserController {
@Autowired(required = false)
private SimpMessagingTemplate simpMessagingTemplate;
//update-begin---author:GHT ---date:2026-06-08 for【XSLMES-20260608】注入钉钉服务用于同步钉钉ID-----------
@Autowired(required = false)
private ThirdAppDingtalkServiceImpl thirdAppDingtalkService;
//update-end---author:GHT ---date:2026-06-08 for【XSLMES-20260608】注入钉钉服务用于同步钉钉ID-----------
private void notifyScadaUserChanged(String action, String userId) {
try {
JSONObject payload = new JSONObject();
@@ -2246,6 +2256,60 @@ public class SysUserController {
* yes_{URL编码后的默认密码} -> 用户当前密码为默认初始密码,前端需弹出强制修改提示
* no -> 用户密码不是默认密码,或未开启默认密码检测开关
*/
//update-begin---author:GHT ---date:2026-06-08 for【XSLMES-20260608】批量同步钉钉用户ID接口-----------
/**
* 批量同步钉钉ID根据用户手机号查询钉钉userId并回写到sys_user.ding_user_id。
* 匹配不到的用户只做提示,不影响其他用户继续匹配。
*/
@Operation(summary = "同步钉钉用户ID")
@PostMapping("/syncDingUserId")
public Result<JSONObject> syncDingUserId() {
if (thirdAppDingtalkService == null) {
return Result.error("钉钉集成未配置,无法同步");
}
String accessToken = thirdAppDingtalkService.getAccessTokenForBackground();
if (oConvertUtils.isEmpty(accessToken)) {
return Result.error("获取钉钉 AccessToken 失败,请检查钉钉应用配置");
}
// 查询所有有手机号的用户
List<SysUser> users = sysUserService.list(
new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<SysUser>()
.isNotNull(SysUser::getPhone)
.ne(SysUser::getPhone, "")
.eq(SysUser::getDelFlag, 0)
);
int successCount = 0;
int failCount = 0;
List<String> failDetails = new ArrayList<>();
for (SysUser user : users) {
try {
Response<String> resp = JdtUserAPI.getUseridByMobile(user.getPhone(), accessToken);
if (resp != null && resp.isSuccess() && oConvertUtils.isNotEmpty(resp.getResult())) {
sysUserService.lambdaUpdate()
.eq(SysUser::getId, user.getId())
.set(SysUser::getDingUserId, resp.getResult())
.update();
successCount++;
} else {
failCount++;
failDetails.add(user.getRealname() + "(" + user.getPhone() + ")");
log.info("[syncDingUserId] 手机号未匹配到钉钉用户 realname={} phone={}", user.getRealname(), user.getPhone());
}
} catch (Exception e) {
failCount++;
failDetails.add(user.getRealname() + "(" + user.getPhone() + ")");
log.warn("[syncDingUserId] 查询钉钉ID异常 realname={} phone={}", user.getRealname(), user.getPhone(), e);
}
}
JSONObject result = new JSONObject();
result.put("successCount", successCount);
result.put("failCount", failCount);
result.put("failDetails", failDetails);
String msg = "同步完成:成功 " + successCount + " 人,未匹配 " + failCount + "";
return Result.OK(msg, result);
}
//update-end---author:GHT ---date:2026-06-08 for【XSLMES-20260608】批量同步钉钉用户ID接口-----------
@GetMapping("/verifyIzDefaultPwd")
public Result<String> verifyIzDefaultPwd() throws UnsupportedEncodingException {
// 未配置 Firewall 或已关闭默认密码检测开关 (enableDefaultPwdCheck=false) 时,直接返回 "no" 表示无需提示

View File

@@ -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功能-----------
}

View File

@@ -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幂等边界)';

View File

@@ -0,0 +1,2 @@
-- 用户表新增钉钉用户ID字段
ALTER TABLE sys_user ADD COLUMN ding_user_id VARCHAR(100) DEFAULT NULL COMMENT '钉钉用户ID';

View File

@@ -0,0 +1,31 @@
-- 混炼示方主表新增状态字段复用配合示方状态字典
-- author: cursor date: 2026-06-08 forXSLMES-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;

View File

@@ -9,6 +9,9 @@
<!-- <j-upload-button type="primary" preIcon="ant-design:import-outlined" @click="onImportXls" v-auth="'system:user:import'">导入</j-upload-button>-->
<import-excel-progress :upload-url="getImportUrl" @success="reload"></import-excel-progress>
<a-button type="primary" @click="openModal(true, {})" preIcon="ant-design:hdd-outlined"> 回收站</a-button>
<!--update-begin---author:GHT ---date:2026-06-08 forXSLMES-20260608同步钉钉ID按钮-->
<a-button type="default" preIcon="ant-design:sync-outlined" :loading="syncDingLoading" @click="handleSyncDingUserId"> 同步钉钉ID</a-button>
<!--update-end---author:GHT ---date:2026-06-08 forXSLMES-20260608同步钉钉ID按钮-->
<a-dropdown v-if="selectedRowKeys.length > 0">
<template #overlay>
<a-menu>
@@ -66,12 +69,33 @@
import { useModal } from '/@/components/Modal';
import { useMessage } from '/@/hooks/web/useMessage';
import { columns, searchFormSchema } from './user.data';
import { listNoCareTenant, deleteUser, batchDeleteUser, getImportUrl, getExportUrl, frozenBatch, resetPassword } from './user.api';
import { listNoCareTenant, deleteUser, batchDeleteUser, getImportUrl, getExportUrl, frozenBatch, resetPassword, syncDingUserId } from './user.api';
import { usePermission } from '/@/hooks/web/usePermission';
import ImportExcelProgress from './components/ImportExcelProgress.vue';
const { createMessage, createConfirm } = useMessage();
const { isDisabledAuth, hasPermission } = usePermission();
//update-begin---author:GHT ---date:2026-06-08 for【XSLMES-20260608】同步钉钉ID-----------
const syncDingLoading = ref(false);
async function handleSyncDingUserId() {
syncDingLoading.value = true;
try {
const res: any = await syncDingUserId();
const { successCount, failCount, failDetails } = res;
let content = `同步完成:成功 ${successCount} 人,未匹配 ${failCount}`;
if (failDetails && failDetails.length > 0) {
content += `\n\n未匹配用户${failDetails.join('、')}`;
}
createMessage.info(content);
reload();
} catch (e) {
createMessage.error('同步钉钉ID失败');
} finally {
syncDingLoading.value = false;
}
}
//update-end---author:GHT ---date:2026-06-08 for【XSLMES-20260608】同步钉钉ID-----------
//注册drawer
const [registerDrawer, { openDrawer }] = useDrawer();

View File

@@ -6,6 +6,9 @@ enum Api {
list = '/sys/user/list',
save = '/sys/user/add',
edit = '/sys/user/edit',
//update-begin---author:GHT ---date:2026-06-08 for【XSLMES-20260608】同步钉钉ID-----------
syncDingUserId = '/sys/user/syncDingUserId',
//update-end---author:GHT ---date:2026-06-08 for【XSLMES-20260608】同步钉钉ID-----------
getUserRole = '/sys/user/queryUserRole',
duplicateCheck = '/sys/duplicate/check',
deleteUser = '/sys/user/delete',
@@ -241,9 +244,16 @@ export const updateUserTenantStatus = (params) => {
/**
* 根据部门id和已选中的部门岗位id获取部门下的岗位id
*
*
* @param params
*/
export const getDepPostIdByDepId = (params) => {
return defHttp.get({ url: Api.getDepPostIdByDepId, params },{ isTransformResponse: false });
};
//update-begin---author:GHT ---date:2026-06-08 for【XSLMES-20260608】同步钉钉ID接口-----------
/**
* 批量同步钉钉ID通过手机号查询钉钉userId回写到用户表
*/
export const syncDingUserId = () => defHttp.post({ url: Api.syncDingUserId });
//update-end---author:GHT ---date:2026-06-08 for【XSLMES-20260608】同步钉钉ID接口-----------

View File

@@ -104,6 +104,14 @@ export const columns: BasicColumn[] = [
width: 80,
resizable: true,
},
//update-begin---author:GHT ---date:2026-06-08 for【XSLMES-20260608】新增钉钉ID列-----------
{
title: '钉钉ID',
dataIndex: 'dingUserId',
width: 120,
resizable: true,
},
//update-end---author:GHT ---date:2026-06-08 for【XSLMES-20260608】新增钉钉ID列-----------
];
export const recycleColumns: BasicColumn[] = [

View File

@@ -4,6 +4,9 @@ enum Api {
list = '/xslmes/mesXslApprovalTrace/list',
queryById = '/xslmes/mesXslApprovalTrace/queryById',
queryByBiz = '/xslmes/mesXslApprovalTrace/queryByBiz',
dingFlowRecords = '/xslmes/mesXslApprovalTrace/dingFlowRecords',
dingProcessForecast = '/xslmes/mesXslApprovalTrace/dingProcessForecast',
dingProcessInstance = '/xslmes/mesXslApprovalTrace/dingProcessInstance',
}
export const list = (params) => defHttp.get({ url: Api.list, params });
@@ -13,3 +16,24 @@ export const queryById = (params: { id: string }) => defHttp.get({ url: Api.quer
/** 按业务表 + 单据ID 查询痕迹(供业务页关联展示) */
export const queryByBiz = (params: { bizTable: string; bizDataId: string }) =>
defHttp.get({ url: Api.queryByBiz, params });
/** 拉取钉钉审批实例操作记录(时间轴) */
export const getDingFlowRecords = (params: {
bizTable?: string;
bizDataId?: string;
processInstanceId?: string;
}) => defHttp.get({ url: Api.dingFlowRecords, params });
/** 从审批实例 tasks 解析审批节点 */
export const getDingProcessForecast = (params: {
bizTable?: string;
bizDataId?: string;
processInstanceId?: string;
}) => defHttp.get({ url: Api.dingProcessForecast, params });
/** 拉取钉钉审批实例接口原始 JSON 响应 */
export const getDingProcessInstance = (params: {
bizTable?: string;
bizDataId?: string;
processInstanceId?: string;
}) => defHttp.get({ url: Api.dingProcessInstance, params });

View File

@@ -17,8 +17,27 @@ export const searchFormSchema: FormSchema[] = [
{ label: '单据ID', field: 'bizDataId', component: 'JInput', colProps: { span: 8 } },
];
const externalInstanceIdColumn: BasicColumn = {
title: '钉钉审批流ID',
dataIndex: 'externalInstanceId',
width: 220,
align: 'left',
ellipsis: true,
};
/** 注册中心抽屉内明细列表(已按业务表过滤,不重复展示业务表列) */
export const drawerColumns: BasicColumn[] = columns.filter((col) => col.dataIndex !== 'bizTable');
export const drawerColumns: BasicColumn[] = [
{
title: '审批操作',
dataIndex: 'flowRecord',
width: 380,
fixed: 'left',
slots: { customRender: 'flowRecord' },
},
...columns
.filter((col) => col.dataIndex !== 'bizTable')
.flatMap((col) => (col.dataIndex === 'bizDataId' ? [col, externalInstanceIdColumn] : [col])),
];
export const drawerSearchFormSchema: FormSchema[] = [
{ label: '单据ID', field: 'bizDataId', component: 'JInput', colProps: { span: 12 } },

View File

@@ -0,0 +1,203 @@
<template>
<BasicModal
v-bind="$attrs"
@register="registerModal"
:title="modalTitle"
width="720px"
:showOkBtn="false"
cancelText="关闭"
destroyOnClose
>
<a-spin :spinning="loading">
<div class="ding-flow-timeline">
<a-descriptions v-if="flowInfo.processInstanceId" :column="2" size="small" bordered class="ding-flow-head">
<a-descriptions-item label="钉钉审批流ID" :span="2">{{ flowInfo.processInstanceId }}</a-descriptions-item>
<a-descriptions-item label="审批标题" :span="2">{{ flowInfo.title || '-' }}</a-descriptions-item>
<a-descriptions-item label="单据ID">{{ flowInfo.bizDataId || '-' }}</a-descriptions-item>
<a-descriptions-item label="实例状态">
<a-tag :color="statusColor">{{ statusText }}</a-tag>
</a-descriptions-item>
</a-descriptions>
<div class="ding-flow-section-title">审批流转记录</div>
<a-empty v-if="!loading && !records.length" description="暂无操作记录" />
<a-timeline v-else class="ding-flow-timeline-list">
<a-timeline-item v-for="(item, index) in records" :key="index" :color="timelineColor(item)">
<div class="ding-flow-line">
<b>{{ operatorText(item) }}</b>
<span class="ding-flow-role">{{ nodeTitle(item) }}</span>
<a-tag v-if="resultText(item.result)" :color="resultColor(item.result)" size="small">
{{ resultText(item.result) }}
</a-tag>
<span class="ding-flow-time">{{ formatDate(item.date) }}</span>
</div>
<div v-if="item.remark" class="ding-flow-remark">意见{{ item.remark }}</div>
</a-timeline-item>
</a-timeline>
</div>
</a-spin>
</BasicModal>
</template>
<script lang="ts" setup>
import { computed, ref } from 'vue';
import { BasicModal, useModalInner } from '/@/components/Modal';
import { useMessage } from '/@/hooks/web/useMessage';
import { getDingFlowRecords } from '../MesXslApprovalTrace.api';
defineOptions({ name: 'DingApprovalFlowTimelineModal' });
const { createMessage } = useMessage();
const loading = ref(false);
const flowInfo = ref<Recordable>({});
const records = ref<Recordable[]>([]);
const modalTitle = computed(() => {
const title = flowInfo.value?.title;
return title ? `审批流转记录 — ${title}` : '审批流转记录';
});
const statusColor = computed(() => {
const status = String(flowInfo.value?.status || '').toUpperCase();
if (status === 'COMPLETED') return 'green';
if (status === 'TERMINATED') return 'red';
if (status === 'RUNNING') return 'blue';
return 'default';
});
const statusText = computed(() => {
const status = String(flowInfo.value?.status || '').toUpperCase();
const map: Record<string, string> = {
RUNNING: '审批中',
COMPLETED: '已完成',
TERMINATED: '已终止',
};
return map[status] || flowInfo.value?.status || '-';
});
const [registerModal, { setModalProps }] = useModalInner(async (data) => {
flowInfo.value = {};
records.value = [];
const record = data?.record || {};
const bizTable = record.bizTable || data?.bizTable;
const bizDataId = record.bizDataId;
const processInstanceId = record.externalInstanceId;
if (!processInstanceId && (!bizTable || !bizDataId)) {
createMessage.warning('缺少单据ID或钉钉审批流ID无法查询流转记录');
return;
}
try {
loading.value = true;
setModalProps({ confirmLoading: false });
const res = await getDingFlowRecords({ bizTable, bizDataId, processInstanceId });
flowInfo.value = res || {};
records.value = res?.operationRecords || [];
} catch (e: any) {
createMessage.error(e?.message || '获取审批流转记录失败');
} finally {
loading.value = false;
}
});
function nodeTitle(item: Recordable) {
const showName = String(item?.showName || '').trim();
if (showName && showName.toUpperCase() !== 'UNKNOWN') {
return showName;
}
return typeText(item?.type);
}
function operatorText(item: Recordable) {
return item?.userName || item?.userId || '-';
}
function formatDate(value?: string) {
if (!value) return '-';
const text = String(value).replace('T', ' ').replace('Z', '');
return text.length > 19 ? text.substring(0, 19) : text;
}
function typeText(type?: string) {
const map: Record<string, string> = {
START_PROCESS: '发起审批',
EXECUTE_TASK_NORMAL: '审批',
EXECUTE_TASK_AGENT: '代办审批',
REDIRECT_PROCESS: '退回',
PROCESS_CC: '抄送',
ADD_REMARK: '评论',
TERMINATE_PROCESS_INSTANCE: '撤销',
FINISH_PROCESS_INSTANCE: '结束审批',
};
return map[String(type || '')] || type || '操作';
}
function resultText(result?: string) {
if (!result) return '';
const map: Record<string, string> = {
AGREE: '同意',
REFUSE: '拒绝',
REDIRECTED: '转交',
NONE: '',
};
const text = map[String(result).toUpperCase()] ?? result;
return text || '';
}
function resultColor(result?: string) {
const val = String(result || '').toUpperCase();
if (val === 'AGREE') return 'green';
if (val === 'REFUSE') return 'red';
if (val === 'REDIRECTED') return 'orange';
return 'default';
}
function timelineColor(item: Recordable) {
const result = String(item?.result || '').toUpperCase();
if (result === 'REFUSE') return 'red';
if (result === 'AGREE') return 'green';
if (String(item?.type || '') === 'REDIRECT_PROCESS') return 'orange';
return 'blue';
}
</script>
<style lang="less" scoped>
.ding-flow-head {
margin-bottom: 12px;
}
.ding-flow-section-title {
margin: 12px 0 8px;
font-size: 14px;
font-weight: 600;
color: #333;
border-left: 3px solid #1677ff;
padding-left: 8px;
}
.ding-flow-line {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
line-height: 1.6;
}
.ding-flow-role {
color: #666;
}
.ding-flow-time {
margin-left: auto;
color: #999;
font-size: 12px;
}
.ding-flow-remark,
.ding-flow-type {
margin-top: 4px;
color: #666;
font-size: 13px;
word-break: break-word;
}
</style>

View File

@@ -0,0 +1,102 @@
<template>
<BasicModal
v-bind="$attrs"
@register="registerModal"
title="审批实例节点"
width="960px"
:showOkBtn="false"
cancelText="关闭"
destroyOnClose
>
<a-spin :spinning="loading">
<a-descriptions v-if="forecastInfo.processInstanceId" :column="2" size="small" bordered class="ding-forecast-head">
<a-descriptions-item label="MES审批流">{{ forecastInfo.mesFlowName || '-' }}</a-descriptions-item>
<a-descriptions-item label="节点来源">{{ forecastInfo.nodeSource || '-' }}</a-descriptions-item>
<a-descriptions-item label="钉钉模板" :span="2">{{ forecastInfo.templateName || '-' }}</a-descriptions-item>
<a-descriptions-item label="processCode" :span="2">{{ forecastInfo.processCode }}</a-descriptions-item>
<a-descriptions-item label="审批实例ID" :span="2">{{ forecastInfo.processInstanceId || '-' }}</a-descriptions-item>
</a-descriptions>
<a-table
:columns="columns"
:data-source="nodes"
:pagination="false"
size="small"
bordered
row-key="stepNo"
:locale="{ emptyText: '暂无审批节点' }"
/>
</a-spin>
</BasicModal>
</template>
<script lang="ts" setup>
import { ref } from 'vue';
import { BasicModal, useModalInner } from '/@/components/Modal';
import { useMessage } from '/@/hooks/web/useMessage';
import { getDingProcessForecast } from '../MesXslApprovalTrace.api';
defineOptions({ name: 'DingApprovalForecastModal' });
const { createMessage } = useMessage();
const loading = ref(false);
const forecastInfo = ref<Recordable>({});
const nodes = ref<Recordable[]>([]);
const columns = [
{ title: '序号', dataIndex: 'stepNo', width: 70, align: 'center' },
{ title: 'activityId', dataIndex: 'activityId', width: 120, ellipsis: true },
{ title: 'MES节点', dataIndex: 'mesNodeName', width: 120, ellipsis: true },
{ title: '钉钉节点', dataIndex: 'activityName', width: 120, ellipsis: true },
{ title: '节点状态', dataIndex: 'nodeStatusText', width: 90, align: 'center' },
{ title: '审批方式', dataIndex: 'approvalMethodText', width: 100, align: 'center' },
{
title: '审批人',
dataIndex: 'actionerNames',
customRender: ({ record }) => formatActioners(record),
},
];
const [registerModal, { setModalProps }] = useModalInner(async (data) => {
forecastInfo.value = {};
nodes.value = [];
const record = data?.record || {};
const bizTable = record.bizTable || data?.bizTable;
const bizDataId = record.bizDataId;
const processInstanceId = record.externalInstanceId;
if (!processInstanceId && (!bizTable || !bizDataId)) {
createMessage.warning('缺少单据ID或钉钉审批流ID无法获取审批节点');
return;
}
try {
loading.value = true;
setModalProps({ confirmLoading: false });
const res = await getDingProcessForecast({ bizTable, bizDataId, processInstanceId });
forecastInfo.value = res || {};
nodes.value = res?.nodes || [];
} catch (e: any) {
createMessage.error(e?.message || '获取审批节点失败');
} finally {
loading.value = false;
}
});
function formatActioners(record: Recordable) {
const names = record?.actionerNames;
if (Array.isArray(names) && names.length) {
return names.join('、');
}
const ids = record?.actionerUserIds;
if (Array.isArray(ids) && ids.length) {
return ids.join('、');
}
return '-';
}
</script>
<style lang="less" scoped>
.ding-forecast-head {
margin-bottom: 12px;
}
</style>

View File

@@ -0,0 +1,89 @@
<template>
<BasicModal
v-bind="$attrs"
@register="registerModal"
title="钉钉审批实例原始JSON"
width="960px"
:defaultFullscreen="true"
:showOkBtn="false"
cancelText="关闭"
destroyOnClose
>
<a-spin :spinning="loading">
<div v-if="instanceId" class="ding-instance-head">
<span>审批实例ID</span>
<span class="ding-instance-id">{{ instanceId }}</span>
</div>
<JCodeEditor
v-if="jsonText"
v-model:value="jsonText"
language="json"
theme="idea"
:fullScreen="false"
:lineNumbers="true"
:disabled="true"
:language-change="false"
height="calc(100vh - 220px)"
/>
<a-empty v-else-if="!loading" description="暂无实例数据" />
</a-spin>
</BasicModal>
</template>
<script lang="ts" setup>
import { ref } from 'vue';
import { BasicModal, useModalInner } from '/@/components/Modal';
import { JCodeEditor } from '/@/components/Form';
import { useMessage } from '/@/hooks/web/useMessage';
import { getDingProcessInstance } from '../MesXslApprovalTrace.api';
import 'codemirror/theme/idea.css';
defineOptions({ name: 'DingApprovalInstanceJsonModal' });
const { createMessage } = useMessage();
const loading = ref(false);
const instanceId = ref('');
const jsonText = ref('');
const [registerModal, { setModalProps }] = useModalInner(async (data) => {
instanceId.value = '';
jsonText.value = '';
const record = data?.record || {};
const bizTable = record.bizTable || data?.bizTable;
const bizDataId = record.bizDataId;
const processInstanceId = record.externalInstanceId;
if (!processInstanceId && (!bizTable || !bizDataId)) {
createMessage.warning('缺少单据ID或钉钉审批流ID无法查看审批实例');
return;
}
instanceId.value = processInstanceId || '';
try {
loading.value = true;
setModalProps({ confirmLoading: false });
const res = await getDingProcessInstance({ bizTable, bizDataId, processInstanceId });
jsonText.value = JSON.stringify(res ?? {}, null, 2);
if (!instanceId.value && res?.result?.processInstanceId) {
instanceId.value = res.result.processInstanceId;
}
} catch (e: any) {
createMessage.error(e?.message || '拉取钉钉审批实例失败');
} finally {
loading.value = false;
}
});
</script>
<style lang="less" scoped>
.ding-instance-head {
margin-bottom: 12px;
font-size: 13px;
color: rgba(0, 0, 0, 0.65);
}
.ding-instance-id {
font-family: Consolas, Monaco, monospace;
color: rgba(0, 0, 0, 0.88);
word-break: break-all;
}
</style>

View File

@@ -1,6 +1,21 @@
<template>
<BasicDrawer @register="registerDrawer" :title="drawerTitle" width="960" destroyOnClose>
<BasicTable @register="registerTable" />
<BasicDrawer @register="registerDrawer" :title="drawerTitle" width="1100" destroyOnClose>
<BasicTable @register="registerTable">
<template #flowRecord="{ record }">
<a-button type="link" size="small" :disabled="!canViewFlow(record)" @click="handleViewInstance(record)">
查看审批实例
</a-button>
<a-button type="link" size="small" :disabled="!canViewFlow(record)" @click="handleViewFlow(record)">
查看审批流转记录
</a-button>
<a-button type="link" size="small" :disabled="!canViewFlow(record)" @click="handleViewForecast(record)">
查看MES审批节点
</a-button>
</template>
</BasicTable>
<DingApprovalInstanceJsonModal @register="registerInstanceModal" />
<DingApprovalFlowTimelineModal @register="registerFlowModal" />
<DingApprovalForecastModal @register="registerForecastModal" />
</BasicDrawer>
</template>
@@ -8,10 +23,17 @@
import { ref, computed } from 'vue';
import { BasicDrawer, useDrawerInner } from '/@/components/Drawer';
import { BasicTable, useTable } from '/@/components/Table';
import { useModal } from '/@/components/Modal';
import { drawerColumns, drawerSearchFormSchema } from '../MesXslApprovalTrace.data';
import { list } from '../MesXslApprovalTrace.api';
import DingApprovalInstanceJsonModal from './DingApprovalInstanceJsonModal.vue';
import DingApprovalFlowTimelineModal from './DingApprovalFlowTimelineModal.vue';
import DingApprovalForecastModal from './DingApprovalForecastModal.vue';
const registryRecord = ref<Recordable>({});
const [registerInstanceModal, { openModal: openInstanceModal }] = useModal();
const [registerFlowModal, { openModal: openFlowModal }] = useModal();
const [registerForecastModal, { openModal: openForecastModal }] = useModal();
const drawerTitle = computed(() => {
const record = registryRecord.value;
@@ -46,4 +68,38 @@
setDrawerProps({ confirmLoading: false });
await reload();
});
function canViewFlow(record: Recordable) {
return !!(record?.externalInstanceId || (record?.bizTable && record?.bizDataId));
}
function handleViewInstance(record: Recordable) {
openInstanceModal(true, {
record: {
...record,
bizTable: record.bizTable || registryRecord.value.tableName,
},
bizTable: registryRecord.value.tableName,
});
}
function handleViewFlow(record: Recordable) {
openFlowModal(true, {
record: {
...record,
bizTable: record.bizTable || registryRecord.value.tableName,
},
bizTable: registryRecord.value.tableName,
});
}
function handleViewForecast(record: Recordable) {
openForecastModal(true, {
record: {
...record,
bizTable: record.bizTable || registryRecord.value.tableName,
},
bizTable: registryRecord.value.tableName,
});
}
</script>

View File

@@ -4,11 +4,17 @@
:title="isUpdate ? '编辑动作' : '添加动作'"
width="860px"
:confirm-loading="saving"
ok-text="确认"
cancel-text="取消"
@ok="handleConfirm"
@cancel="visible = false"
>
<template #footer>
<div style="display: flex; justify-content: space-between; align-items: center; width: 100%">
<a-button @click="handleReset">清空配置</a-button>
<a-space>
<a-button @click="visible = false">取消</a-button>
<a-button type="primary" :loading="saving" @click="handleConfirm">确认</a-button>
</a-space>
</div>
</template>
<a-form ref="formRef" :model="form" :label-col="{ span: 5 }" :wrapper-col="{ span: 18 }" style="margin-top: 12px">
<a-row :gutter="16">
<a-col :span="15">
@@ -150,43 +156,12 @@
<!-- ============ 状态修改全新设计 ============ -->
<template v-if="vc.visualType === 'STATUS_MODIFY'">
<!-- 触发表节点识别可选 -->
<div style="background: #fffbe6; border: 1px solid #ffe58f; border-radius: 6px; padding: 12px 14px; margin-bottom: 14px">
<div style="font-size: 12px; font-weight: 600; color: #d48806; margin-bottom: 8px">
🎯 触发表节点识别可选
<span style="font-weight: 400; color: #888; margin-left: 6px">仅在 onNodeApprove 时需要用于区分哪个节点触发了此动作</span>
</div>
<div style="display: flex; align-items: flex-end; gap: 8px">
<span style="font-size: 12px; color: #888; padding-bottom: 5px; white-space: nowrap"> {{ sourceTable }} </span>
<div style="flex: 1">
<a-select
v-model:value="vc.statusConfig!.srcConditionField"
:options="sourceFieldOpts"
placeholder="字段名,如 status"
allow-clear
show-search
style="width: 100%"
@change="onSrcConditionFieldChange"
/>
</div>
<span style="font-size: 14px; color: #d48806; font-weight: 600; padding-bottom: 5px"></span>
<div style="flex: 1">
<a-select
v-if="srcConditionDictCode && vc.statusConfig!.srcConditionField"
v-model:value="vc.statusConfig!.srcConditionValue"
:options="srcConditionDictItems"
placeholder="选择状态值"
allow-clear
show-search
style="width: 100%"
/>
<a-input
v-else
v-model:value="vc.statusConfig!.srcConditionValue"
:placeholder="vc.statusConfig!.srcConditionField ? '如 compile' : '选择左侧字段后填写'"
:disabled="!vc.statusConfig!.srcConditionField"
/>
</div>
<!-- 节点触发说明 -->
<div style="background: #e6f4ff; border: 1px solid #91caff; border-radius: 6px; padding: 12px 14px; margin-bottom: 14px">
<div style="font-size: 12px; font-weight: 600; color: #0958d9; margin-bottom: 6px">节点触发说明</div>
<div style="font-size: 12px; color: #555; line-height: 1.6">
本动作何时执行由集成方案的<strong>触发时机</strong><strong>绑定环节</strong>控制 onNodeApprove + proofread无需在 SQL 中判断源单 status
源单状态推进请使用环节同步动作本动作仅负责更新<strong>关联目标表</strong>
</div>
</div>
@@ -313,6 +288,7 @@
<script lang="ts" setup>
import { ref, computed, watch } from 'vue';
import { Modal } from 'ant-design-vue';
import { useMessage } from '/@/hooks/web/useMessage';
import { getTableColumns, getDictItems } from '../MesXslIntegrationPlan.api';
@@ -351,6 +327,13 @@
mes_xsl_formula_spec: 'xslmes_formula_spec_status',
};
/** 目标表 status 字段未带字典注释时的兜底映射 */
const TARGET_TABLE_STATUS_DICT: Record<string, string> = {
mes_xsl_mixer_ps_compile: 'xslmes_mixer_ps_status',
mes_xsl_formula_spec: 'xslmes_formula_spec_status',
mes_xsl_mixing_spec: 'xslmes_formula_spec_status',
};
const ACTION_TYPES = [
{ value: 'REGISTRY_STAGE_SYNC', icon: '✅', label: '审批环节同步', desc: '按注册中心更新源单状态+操作人+痕迹', disabled: false },
{ value: 'REGISTRY_STAGE_REVERT', icon: '↩️', label: '审批环节回退', desc: '驳回时回退源单并清空痕迹', disabled: false },
@@ -474,11 +457,8 @@
watch(
() => vc.value.statusConfig?.targetField,
async (field) => {
if (!field) { targetStatusDictCode.value = ''; targetStatusDictItems.value = []; return; }
const dc = extractDictCode(targetColumns.value.find((c) => c.columnName === field)?.comment || '');
targetStatusDictCode.value = dc || '';
targetStatusDictItems.value = dc ? await loadDict(dc) : [];
}
await loadTargetStatusDict(field);
},
);
// 审批环节变化时,前置状态留空则填入默认推断值
@@ -505,11 +485,28 @@
function extractDictCode(comment: string): string | null {
if (!comment) return null;
// 匹配 "字典xslmes_xxx" "字典:xslmes_xxx" "字典 xslmes_xxx"
const m = comment.match(/字典[:\s]?([a-zA-Z][a-zA-Z0-9_]*)/);
// 匹配 "字典xslmes_xxx" / "字典:xslmes_xxx" / "字典 xslmes_xxx" / 全角括号包裹
const m = comment.match(/字典[:\s]?([a-zA-Z][a-zA-Z0-9_]*)/);
return m ? m[1] : null;
}
/** 按目标表字段注释(或表名兜底)加载 status 字典,供前置状态/新状态下拉 */
async function loadTargetStatusDict(field?: string) {
const f = field || vc.value.statusConfig?.targetField;
if (!f) {
targetStatusDictCode.value = '';
targetStatusDictItems.value = [];
return;
}
const col = targetColumns.value.find((c) => c.columnName === f);
let dictCode = extractDictCode(col?.comment || '');
if (!dictCode && vc.value.targetTable) {
dictCode = TARGET_TABLE_STATUS_DICT[vc.value.targetTable] || '';
}
targetStatusDictCode.value = dictCode || '';
targetStatusDictItems.value = dictCode ? await loadDict(dictCode) : [];
}
async function loadDict(dictCode: string): Promise<any[]> {
if (dictCache.value[dictCode]) return dictCache.value[dictCode];
try {
@@ -561,6 +558,17 @@
return nonStage?.value || items[0].value;
}
/** 保存时扁平化 registryStage兼容引擎解析 stage/expectedFrom/targetStage */
function serializeActionConfig(config: VisualConfig): string {
const payload: Record<string, any> = { ...config };
if (config.registryStage?.stage) payload.stage = config.registryStage.stage;
if (config.registryStage?.expectedFrom !== undefined && config.registryStage?.expectedFrom !== null) {
payload.expectedFrom = config.registryStage.expectedFrom;
}
if (config.registryStage?.targetStage) payload.targetStage = config.registryStage.targetStage;
return JSON.stringify(payload);
}
function buildSql(config: VisualConfig): string {
const { targetTable, linkCondition, visualType, statusConfig, fieldMappings } = config;
if (!targetTable || !linkCondition.sourceField || !linkCondition.targetField) return '';
@@ -573,9 +581,6 @@
if (s.addUpdateTime) sets.push('update_time=NOW()');
const conditions = [baseWhere];
if (s.fromValue) conditions.push(`${s.targetField}='${s.fromValue}'`);
if (s.srcConditionField && s.srcConditionValue) {
conditions.push(`#{source.${s.srcConditionField}}='${s.srcConditionValue}'`);
}
return `UPDATE ${targetTable} SET ${sets.join(', ')} WHERE ${conditions.join(' AND ')}`;
}
@@ -624,21 +629,22 @@
targetColumns.value = (cols as any) || [];
const doc = bizDocList.value.find((d) => d.tableName === tableName);
vc.value.targetTableLabel = getDocLabel(doc);
// 重置状态字段的字典缓存
targetStatusDictCode.value = '';
targetStatusDictItems.value = [];
// 列元数据就绪后重载字典编辑场景targetField 已存在但列尚未加载)
await loadTargetStatusDict();
} catch {
targetColumns.value = [];
targetStatusDictCode.value = '';
targetStatusDictItems.value = [];
} finally {
loadingTargetCols.value = false;
}
}
async function onTargetStatusFieldChange(field: string) {
if (!field) { targetStatusDictCode.value = ''; targetStatusDictItems.value = []; return; }
// 清空旧值,防止值不匹配新字典
vc.value.statusConfig!.fromValue = '';
vc.value.statusConfig!.newValue = '';
await loadTargetStatusDict(field);
}
async function onSrcConditionFieldChange(field: string) {
@@ -655,6 +661,40 @@
vc.value.fieldMappings.push({ targetField: '', sourceType: 'source_field', sourceValue: '' });
}
/** 清空当前可视化配置,保留执行顺序/失败策略/启用状态(编辑时不删动作记录) */
function handleReset() {
Modal.confirm({
title: '清空配置',
content:
'将清空动作名称、目标表、关联条件、状态变更等可视化配置,可重新填写。执行顺序、失败策略、启用状态会保留。确认清空?',
okText: '确认清空',
cancelText: '取消',
onOk: () => {
const keep = { ...form.value };
form.value = {
id: keep.id || '',
actionName: '',
execOrder: keep.execOrder ?? 0,
onFail: keep.onFail || 'stop',
enabled: keep.enabled !== false && keep.enabled !== 0,
};
vc.value = defaultVc();
targetColumns.value = [];
targetStatusDictCode.value = '';
targetStatusDictItems.value = [];
srcConditionDictCode.value = '';
srcConditionDictItems.value = [];
if (registryStageOptions.value.length) {
vc.value.registryStage!.stage = registryStageOptions.value[0].value;
vc.value.registryStage!.expectedFrom = defaultExpectedFromForStage(vc.value.registryStage!.stage);
}
vc.value.registryStage!.targetStage = defaultRevertTargetStage();
formRef.value?.clearValidate?.();
createMessage.success('已清空,请重新配置后点确认保存');
},
});
}
async function handleConfirm() {
try { await formRef.value?.validate(); } catch { return; }
if (vc.value.visualType === 'REGISTRY_STAGE_SYNC') {
@@ -666,7 +706,7 @@
...form.value,
actionType: 'REGISTRY_STAGE_SYNC',
sqlTemplate: null,
actionConfig: JSON.stringify(vc.value),
actionConfig: serializeActionConfig(vc.value),
});
visible.value = false;
return;
@@ -676,7 +716,7 @@
...form.value,
actionType: 'REGISTRY_STAGE_REVERT',
sqlTemplate: null,
actionConfig: JSON.stringify(vc.value),
actionConfig: serializeActionConfig(vc.value),
});
visible.value = false;
return;
@@ -689,7 +729,7 @@
...form.value,
actionType: 'SQL_UPDATE',
sqlTemplate: sql,
actionConfig: JSON.stringify(vc.value),
actionConfig: serializeActionConfig(vc.value),
});
visible.value = false;
}
@@ -722,7 +762,7 @@
const parsed = JSON.parse(a.actionConfig);
vc.value = normalizeParsedConfig(parsed, a.actionType);
if (vc.value.targetTable) {
onTargetTableChange(vc.value.targetTable);
await onTargetTableChange(vc.value.targetTable);
}
} catch {
vc.value = normalizeParsedConfig(null, a.actionType);

View File

@@ -37,6 +37,9 @@ export const columns: BasicColumn[] = [
{ title: '机台', align: 'center', dataIndex: 'machineName', width: 120 },
{ title: '制作日期', align: 'center', dataIndex: 'makeDate', width: 120 },
{ title: '发行编号', align: 'center', dataIndex: 'issueNumber', width: 150 },
//update-begin---author:cursor ---date:20260608 for【XSLMES-20260608-A01】混炼示方列表新增状态列-----------
{ title: '状态', align: 'center', dataIndex: 'status_dictText', width: 120 },
//update-end---author:cursor ---date:20260608 for【XSLMES-20260608-A01】混炼示方列表新增状态列-----------
{ title: '段数', align: 'center', dataIndex: 'stageCount', width: 88 },
{ title: '纯混炼时间(秒)', align: 'center', dataIndex: 'pureMixSec', width: 130 },
{ title: '变更日期', align: 'center', dataIndex: 'changeDate', width: 120 },
@@ -45,6 +48,15 @@ export const columns: BasicColumn[] = [
export const searchFormSchema: FormSchema[] = [
{ label: '关键字', field: 'keyword', component: 'Input', colProps: { span: 8 }, componentProps: { placeholder: '规格/用途/发行编号/机台' } },
//update-begin---author:cursor ---date:20260608 for【XSLMES-20260608-A01】混炼示方查询新增状态条件-----------
{
label: '状态',
field: 'status',
component: 'JDictSelectTag',
componentProps: { dictCode: 'xslmes_formula_spec_status', placeholder: '请选择状态' },
colProps: { span: 8 },
},
//update-end---author:cursor ---date:20260608 for【XSLMES-20260608-A01】混炼示方查询新增状态条件-----------
{ label: '制作日期起', field: 'makeDate_begin', component: 'DatePicker', colProps: { span: 8 }, componentProps: { valueFormat: 'YYYY-MM-DD' } },
{ label: '制作日期止', field: 'makeDate_end', component: 'DatePicker', colProps: { span: 8 }, componentProps: { valueFormat: 'YYYY-MM-DD' } },
];
@@ -1327,9 +1339,11 @@ export function resolveMixingSpecFormulaStatus(record: Recordable = {}): string
return '编制中';
}
/** 混炼示方是否允许编辑/删除(与配合示方一致:密炼PS校对后锁定 */
/** 混炼示方是否允许编辑/删除(与配合示方一致:仅编制状态可改 */
export function isMixingSpecEditable(record: Recordable = {}): boolean {
return !record?.proofreadBy && !record?.proofreadTime;
//update-begin---author:cursor ---date:20260608 for【XSLMES-20260608-A01】混炼示方编辑权限按状态字段判断-----------
return !record?.status || record.status === 'compile';
//update-end---author:cursor ---date:20260608 for【XSLMES-20260608-A01】混炼示方编辑权限按状态字段判断-----------
}
/** 参照历史混合步骤:混炼示方选择列表列 */
@@ -1365,6 +1379,9 @@ export const MIXING_SPEC_MAIN_STRIP_FIELDS = [
'approveBy',
'approveTime',
'changeDate',
//update-begin---author:cursor ---date:20260608 for【XSLMES-20260608-A01】参照新增时重置状态-----------
'status',
//update-end---author:cursor ---date:20260608 for【XSLMES-20260608-A01】参照新增时重置状态-----------
'delFlag',
'tenantId',
'sysOrgCode',