钉钉审批配置优化
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -5,6 +5,7 @@ import lombok.experimental.Accessors;
|
||||
import org.jeecg.modules.xslmes.approval.entity.MesXslApprovalInstance;
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.util.Date;
|
||||
|
||||
/**
|
||||
* 审批回调上下文。
|
||||
@@ -27,7 +28,11 @@ public class ApprovalCallbackContext implements Serializable {
|
||||
/** 整个流程最终通过 */
|
||||
APPROVED,
|
||||
/** 被驳回(任一节点驳回即终止) */
|
||||
REJECTED
|
||||
REJECTED,
|
||||
//update-begin---author:GHT ---date:2026-06-08 for:【风险修复-R5】新增CANCELLED动作,支持撤销时触发业务回滚回调-----------
|
||||
/** 流程被撤销/终止(TERMINATED) */
|
||||
CANCELLED
|
||||
//update-end---author:GHT ---date:2026-06-08 for:【风险修复-R5】新增CANCELLED动作,支持撤销时触发业务回滚回调-----------
|
||||
}
|
||||
|
||||
/** 回调动作 */
|
||||
@@ -76,6 +81,11 @@ public class ApprovalCallbackContext implements Serializable {
|
||||
/** 操作人姓名 */
|
||||
private String operatorName;
|
||||
|
||||
//update-begin---author:GHT ---date:20260608 for:【审批注册中心】环节同步使用实例tasks最新完成时间-----------
|
||||
/** 操作时间(钉钉回调时为 tasks 最新 finishTime) */
|
||||
private Date operatorTime;
|
||||
//update-end---author:GHT ---date:20260608 for:【审批注册中心】环节同步使用实例tasks最新完成时间-----------
|
||||
|
||||
/** 审批意见 / 驳回理由 */
|
||||
private String comment;
|
||||
|
||||
@@ -87,4 +97,18 @@ public class ApprovalCallbackContext implements Serializable {
|
||||
|
||||
/** 完整审批实例(供业务读取租户、发起信息等) */
|
||||
private transient MesXslApprovalInstance instance;
|
||||
|
||||
//update-begin---author:GHT ---date:2026-06-08 for:【缺陷修复-D1/D2】新增token和activityId字段,支持钉钉回调时传递真实审批人身份及节点精确定位-----------
|
||||
/**
|
||||
* 操作人JWT Token(钉钉回调时为审批人真实身份Token;MES内部审批时为null)。
|
||||
* 供 ApprovalActionHttpExecutor 等需要身份的调用方使用。
|
||||
*/
|
||||
private transient String token;
|
||||
|
||||
/**
|
||||
* 钉钉任务节点ID(operationRecords[].activityId,仅钉钉通道有值)。
|
||||
* 可供集成引擎或业务回调按节点精确匹配。
|
||||
*/
|
||||
private String activityId;
|
||||
//update-end---author:GHT ---date:2026-06-08 for:【缺陷修复-D1/D2】新增token和activityId字段,支持钉钉回调时传递真实审批人身份及节点精确定位-----------
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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时通知业务回滚中间态状态-----------
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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-----------
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -0,0 +1,594 @@
|
||||
package org.jeecg.modules.xslmes.approval.integration.engine;
|
||||
|
||||
import com.alibaba.fastjson2.JSONArray;
|
||||
import com.alibaba.fastjson2.JSONObject;
|
||||
import lombok.Data;
|
||||
import lombok.experimental.Accessors;
|
||||
import org.jeecg.common.util.oConvertUtils;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.text.ParseException;
|
||||
import java.text.SimpleDateFormat;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collection;
|
||||
import java.util.Comparator;
|
||||
import java.util.Date;
|
||||
import java.util.HashMap;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* 从钉钉审批实例 tasks 按 activityId 解析环节完成情况,并与 MES 审批流节点 stageKey / multiMode 对齐。
|
||||
*/
|
||||
@Component
|
||||
public class ApprovalInstanceStageExtractor {
|
||||
|
||||
private static final Set<String> TRACE_STAGES = Set.of(
|
||||
ApprovalStageResolver.STAGE_PROOFREAD,
|
||||
ApprovalStageResolver.STAGE_AUDIT,
|
||||
ApprovalStageResolver.STAGE_APPROVE);
|
||||
|
||||
private final JdbcTemplate jdbcTemplate;
|
||||
|
||||
public ApprovalInstanceStageExtractor(JdbcTemplate jdbcTemplate) {
|
||||
this.jdbcTemplate = jdbcTemplate;
|
||||
}
|
||||
|
||||
//update-begin---author:GHT ---date:20260608 for:【审批注册中心】按MES multiMode解析节点状态与审批人-----------
|
||||
/**
|
||||
* 按 MES 审批流节点顺序与实例 tasks 的 activityId 顺序对齐,解析各环节已完成操作人及最新时间。
|
||||
*/
|
||||
public List<StageCompletion> resolveCompletedStages(JSONObject instance, String flowConfig) {
|
||||
List<StageCompletion> completions = new ArrayList<>();
|
||||
if (instance == null || oConvertUtils.isEmpty(flowConfig)) {
|
||||
return completions;
|
||||
}
|
||||
List<NodePair> pairs = alignMesNodesWithTasks(instance, flowConfig);
|
||||
for (NodePair pair : pairs) {
|
||||
String stageKey = resolveStageKey(pair.getMesNode());
|
||||
if (!isTraceStage(stageKey)) {
|
||||
continue;
|
||||
}
|
||||
NodeTaskDecision decision = evaluateNodeTasks(pair.getTaskList(), resolveApprovalMethod(pair.getMesNode()));
|
||||
if (!decision.isAgreed()) {
|
||||
continue;
|
||||
}
|
||||
StageCompletion completion = toStageCompletion(stageKey, pair.getActivityId(), decision);
|
||||
if (completion != null) {
|
||||
completions.add(completion);
|
||||
}
|
||||
}
|
||||
return completions;
|
||||
}
|
||||
|
||||
public LinkedHashMap<String, List<JSONObject>> groupTasksByActivityId(JSONObject instance) {
|
||||
LinkedHashMap<String, List<JSONObject>> grouped = new LinkedHashMap<>();
|
||||
JSONArray tasks = instance.getJSONArray("tasks");
|
||||
if (tasks == null || tasks.isEmpty()) {
|
||||
return grouped;
|
||||
}
|
||||
for (int i = 0; i < tasks.size(); i++) {
|
||||
JSONObject task = tasks.getJSONObject(i);
|
||||
if (task == null) {
|
||||
continue;
|
||||
}
|
||||
String activityId = task.getString("activityId");
|
||||
if (oConvertUtils.isEmpty(activityId)) {
|
||||
continue;
|
||||
}
|
||||
grouped.computeIfAbsent(activityId, k -> new ArrayList<>()).add(task);
|
||||
}
|
||||
return grouped;
|
||||
}
|
||||
|
||||
public List<String> listOrderedActivityIds(JSONObject instance) {
|
||||
return new ArrayList<>(groupTasksByActivityId(instance).keySet());
|
||||
}
|
||||
|
||||
public int resolveStepIndexFromTasks(JSONObject instance, String activityId) {
|
||||
if (instance == null || oConvertUtils.isEmpty(activityId)) {
|
||||
return -1;
|
||||
}
|
||||
return listOrderedActivityIds(instance).indexOf(activityId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 提取某 activityId 节点完成时的审批人及时间(按 MES multiMode 判定)。
|
||||
*/
|
||||
public StageCompletion extractActivityCompletion(JSONObject instance, String activityId, JSONObject mesNode) {
|
||||
if (instance == null || oConvertUtils.isEmpty(activityId)) {
|
||||
return null;
|
||||
}
|
||||
List<JSONObject> taskList = groupTasksByActivityId(instance).get(activityId);
|
||||
String approvalMethod = mesNode == null ? "NONE" : resolveApprovalMethod(mesNode);
|
||||
NodeTaskDecision decision = evaluateNodeTasks(taskList, approvalMethod);
|
||||
if (!decision.isAgreed()) {
|
||||
return null;
|
||||
}
|
||||
return toStageCompletion(null, activityId, decision);
|
||||
}
|
||||
|
||||
public List<JSONObject> loadMesApproverNodes(String flowConfig) {
|
||||
List<JSONObject> result = new ArrayList<>();
|
||||
if (oConvertUtils.isEmpty(flowConfig)) {
|
||||
return result;
|
||||
}
|
||||
try {
|
||||
collectAllApproverNodes(JSONObject.parseObject(flowConfig), result);
|
||||
} catch (Exception ignored) {
|
||||
// 解析失败返回空列表
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
public List<NodePair> alignMesNodesWithTasks(JSONObject instance, String flowConfig) {
|
||||
List<NodePair> pairs = new ArrayList<>();
|
||||
LinkedHashMap<String, List<JSONObject>> grouped = groupTasksByActivityId(instance);
|
||||
if (grouped.isEmpty()) {
|
||||
return pairs;
|
||||
}
|
||||
List<JSONObject> mesNodes = loadMesApproverNodes(flowConfig);
|
||||
if (mesNodes.isEmpty()) {
|
||||
return pairs;
|
||||
}
|
||||
List<String> activityOrder = new ArrayList<>(grouped.keySet());
|
||||
int pairCount = Math.min(mesNodes.size(), activityOrder.size());
|
||||
for (int i = 0; i < pairCount; i++) {
|
||||
NodePair pair = new NodePair();
|
||||
pair.setStepNo(i + 1);
|
||||
pair.setMesNode(mesNodes.get(i));
|
||||
pair.setActivityId(activityOrder.get(i));
|
||||
pair.setTaskList(grouped.get(activityOrder.get(i)));
|
||||
pairs.add(pair);
|
||||
}
|
||||
return pairs;
|
||||
}
|
||||
|
||||
public String resolveStageKey(JSONObject mesNode) {
|
||||
if (mesNode == null) {
|
||||
return null;
|
||||
}
|
||||
JSONObject props = mesNode.getJSONObject("props");
|
||||
if (props == null) {
|
||||
return null;
|
||||
}
|
||||
String stageKey = props.getString("stageKey");
|
||||
return oConvertUtils.isEmpty(stageKey) ? null : stageKey.trim();
|
||||
}
|
||||
|
||||
/** 从 MES 审批流节点 props.multiMode 映射钉钉审批方式 */
|
||||
public String resolveApprovalMethod(JSONObject mesNode) {
|
||||
if (mesNode == null) {
|
||||
return "NONE";
|
||||
}
|
||||
JSONObject props = mesNode.getJSONObject("props");
|
||||
if (props == null) {
|
||||
return "NONE";
|
||||
}
|
||||
String multiMode = props.getString("multiMode");
|
||||
if (oConvertUtils.isEmpty(multiMode) || "none".equalsIgnoreCase(multiMode)) {
|
||||
return "NONE";
|
||||
}
|
||||
if ("or".equalsIgnoreCase(multiMode)) {
|
||||
return "OR";
|
||||
}
|
||||
if ("and".equalsIgnoreCase(multiMode)) {
|
||||
return "AND";
|
||||
}
|
||||
if ("sequence".equalsIgnoreCase(multiMode)) {
|
||||
return "ONE_BY_ONE";
|
||||
}
|
||||
return "NONE";
|
||||
}
|
||||
|
||||
public String approvalMethodText(String approvalMethod) {
|
||||
if (oConvertUtils.isEmpty(approvalMethod)) {
|
||||
return "单人审批";
|
||||
}
|
||||
return switch (approvalMethod.toUpperCase()) {
|
||||
case "AND" -> "会签";
|
||||
case "OR" -> "或签";
|
||||
case "ONE_BY_ONE" -> "依次审批";
|
||||
case "NONE" -> "单人审批";
|
||||
default -> approvalMethod;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 按审批方式解析节点状态与应展示的审批人。
|
||||
* 或签:任一通过/拒绝即定论,只取实际操作人;会签:全部通过才完成,取全部通过人。
|
||||
*/
|
||||
public NodeTaskDecision evaluateNodeTasks(List<JSONObject> taskList, String approvalMethod) {
|
||||
NodeTaskDecision decision = new NodeTaskDecision();
|
||||
decision.setNodeStatus("UNKNOWN");
|
||||
if (taskList == null || taskList.isEmpty()) {
|
||||
decision.setNodeStatus("NEW");
|
||||
decision.setNodeStatusText(nodeStatusText("NEW"));
|
||||
return decision;
|
||||
}
|
||||
String method = normalizeApprovalMethod(approvalMethod);
|
||||
|
||||
JSONObject refuseTask = findFirstActedTask(taskList, "REFUSE");
|
||||
if (refuseTask != null) {
|
||||
decision.setNodeStatus("REFUSED");
|
||||
decision.setNodeStatusText(nodeStatusText("REFUSED"));
|
||||
decision.setRefused(true);
|
||||
decision.setActorUserIds(List.of(refuseTask.getString("userId")));
|
||||
decision.setOperatorTime(parseFinishTime(refuseTask.getString("finishTime")));
|
||||
return decision;
|
||||
}
|
||||
|
||||
if ("OR".equals(method)) {
|
||||
JSONObject agreeTask = findFirstActedTask(taskList, "AGREE");
|
||||
if (agreeTask != null) {
|
||||
decision.setNodeStatus("COMPLETED");
|
||||
decision.setNodeStatusText(nodeStatusText("COMPLETED"));
|
||||
decision.setAgreed(true);
|
||||
decision.setActorUserIds(List.of(agreeTask.getString("userId")));
|
||||
decision.setOperatorTime(parseFinishTime(agreeTask.getString("finishTime")));
|
||||
return decision;
|
||||
}
|
||||
return decisionFromPendingTasks(taskList);
|
||||
}
|
||||
|
||||
if ("AND".equals(method) || "ONE_BY_ONE".equals(method)) {
|
||||
if (hasRunningOrNew(taskList)) {
|
||||
decision.setNodeStatus("RUNNING");
|
||||
decision.setNodeStatusText(nodeStatusText("RUNNING"));
|
||||
decision.setActorUserIds(listAllAssigneeIds(taskList));
|
||||
return decision;
|
||||
}
|
||||
List<JSONObject> agreeTasks = findAllActedTasks(taskList, "AGREE");
|
||||
int activeCount = countActiveTasks(taskList);
|
||||
if (activeCount > 0 && agreeTasks.size() >= activeCount) {
|
||||
decision.setNodeStatus("COMPLETED");
|
||||
decision.setNodeStatusText(nodeStatusText("COMPLETED"));
|
||||
decision.setAgreed(true);
|
||||
decision.setActorUserIds(extractOrderedUserIds(agreeTasks));
|
||||
decision.setOperatorTime(latestFinishTime(agreeTasks));
|
||||
return decision;
|
||||
}
|
||||
return decisionFromPendingTasks(taskList);
|
||||
}
|
||||
|
||||
// 单人审批
|
||||
JSONObject agreeTask = findFirstActedTask(taskList, "AGREE");
|
||||
if (agreeTask != null) {
|
||||
decision.setNodeStatus("COMPLETED");
|
||||
decision.setNodeStatusText(nodeStatusText("COMPLETED"));
|
||||
decision.setAgreed(true);
|
||||
decision.setActorUserIds(List.of(agreeTask.getString("userId")));
|
||||
decision.setOperatorTime(parseFinishTime(agreeTask.getString("finishTime")));
|
||||
return decision;
|
||||
}
|
||||
if (hasRunningOrNew(taskList)) {
|
||||
decision.setNodeStatus("RUNNING");
|
||||
decision.setNodeStatusText(nodeStatusText("RUNNING"));
|
||||
decision.setActorUserIds(listAllAssigneeIds(taskList));
|
||||
return decision;
|
||||
}
|
||||
return decisionFromPendingTasks(taskList);
|
||||
}
|
||||
|
||||
public boolean isNodeCompleted(List<JSONObject> taskList, String approvalMethod) {
|
||||
NodeTaskDecision decision = evaluateNodeTasks(taskList, approvalMethod);
|
||||
return decision.isAgreed();
|
||||
}
|
||||
|
||||
/** 审批实例是否已拒绝或终止(此时不应反写已通过环节的痕迹) */
|
||||
public boolean isInstanceRejectedOrCancelled(JSONObject instance) {
|
||||
if (instance == null) {
|
||||
return false;
|
||||
}
|
||||
String result = instance.getString("result");
|
||||
if (oConvertUtils.isNotEmpty(result) && "refuse".equalsIgnoreCase(result.trim())) {
|
||||
return true;
|
||||
}
|
||||
String status = instance.getString("status");
|
||||
if (oConvertUtils.isEmpty(status)) {
|
||||
return false;
|
||||
}
|
||||
String normalized = status.trim().toUpperCase();
|
||||
return "TERMINATED".equals(normalized) || "CANCELED".equals(normalized) || "CANCELLED".equals(normalized);
|
||||
}
|
||||
|
||||
public String nodeStatusText(String nodeStatus) {
|
||||
if (oConvertUtils.isEmpty(nodeStatus)) {
|
||||
return "未知";
|
||||
}
|
||||
return switch (nodeStatus.toUpperCase()) {
|
||||
case "COMPLETED" -> "已完成";
|
||||
case "RUNNING" -> "进行中";
|
||||
case "REFUSED" -> "已拒绝";
|
||||
case "CANCELED" -> "已取消";
|
||||
case "NEW" -> "未启动";
|
||||
default -> nodeStatus;
|
||||
};
|
||||
}
|
||||
|
||||
public List<String> resolveActorNames(List<String> dtUserIds) {
|
||||
if (dtUserIds == null || dtUserIds.isEmpty()) {
|
||||
return new ArrayList<>();
|
||||
}
|
||||
Map<String, String> nameMap = batchResolveDtUserDisplayNames(dtUserIds);
|
||||
return dtUserIds.stream().map(id -> nameMap.getOrDefault(id, id)).collect(Collectors.toList());
|
||||
}
|
||||
|
||||
private NodeTaskDecision decisionFromPendingTasks(List<JSONObject> taskList) {
|
||||
NodeTaskDecision decision = new NodeTaskDecision();
|
||||
if (hasRunningOrNew(taskList)) {
|
||||
decision.setNodeStatus("RUNNING");
|
||||
decision.setNodeStatusText(nodeStatusText("RUNNING"));
|
||||
decision.setActorUserIds(listAllAssigneeIds(taskList));
|
||||
return decision;
|
||||
}
|
||||
if (allCanceled(taskList)) {
|
||||
decision.setNodeStatus("CANCELED");
|
||||
decision.setNodeStatusText(nodeStatusText("CANCELED"));
|
||||
decision.setActorUserIds(listAllAssigneeIds(taskList));
|
||||
return decision;
|
||||
}
|
||||
decision.setNodeStatus("NEW");
|
||||
decision.setNodeStatusText(nodeStatusText("NEW"));
|
||||
decision.setActorUserIds(listAllAssigneeIds(taskList));
|
||||
return decision;
|
||||
}
|
||||
|
||||
private StageCompletion toStageCompletion(String stageKey, String activityId, NodeTaskDecision decision) {
|
||||
if (decision == null || !decision.isAgreed() || decision.getActorUserIds() == null
|
||||
|| decision.getActorUserIds().isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
List<String> names = resolveActorNames(decision.getActorUserIds());
|
||||
StageCompletion completion = new StageCompletion();
|
||||
completion.setStage(stageKey);
|
||||
completion.setActivityId(activityId);
|
||||
completion.setOperatorBy(String.join("、", names));
|
||||
completion.setOperatorTime(decision.getOperatorTime() == null ? new Date() : decision.getOperatorTime());
|
||||
completion.setDtUserIds(decision.getActorUserIds());
|
||||
return completion;
|
||||
}
|
||||
|
||||
private String normalizeApprovalMethod(String approvalMethod) {
|
||||
return oConvertUtils.isEmpty(approvalMethod) ? "NONE" : approvalMethod.trim().toUpperCase();
|
||||
}
|
||||
|
||||
private JSONObject findFirstActedTask(List<JSONObject> taskList, String result) {
|
||||
return taskList.stream()
|
||||
.filter(task -> task != null && "COMPLETED".equalsIgnoreCase(task.getString("status")))
|
||||
.filter(task -> result.equalsIgnoreCase(task.getString("result")))
|
||||
.min(Comparator.comparing(task -> {
|
||||
Date time = parseFinishTime(task.getString("finishTime"));
|
||||
return time == null ? new Date(Long.MAX_VALUE) : time;
|
||||
}))
|
||||
.orElse(null);
|
||||
}
|
||||
|
||||
private List<JSONObject> findAllActedTasks(List<JSONObject> taskList, String result) {
|
||||
List<JSONObject> list = new ArrayList<>();
|
||||
for (JSONObject task : taskList) {
|
||||
if (task == null) {
|
||||
continue;
|
||||
}
|
||||
if ("COMPLETED".equalsIgnoreCase(task.getString("status"))
|
||||
&& result.equalsIgnoreCase(task.getString("result"))) {
|
||||
list.add(task);
|
||||
}
|
||||
}
|
||||
return list;
|
||||
}
|
||||
|
||||
private List<String> extractOrderedUserIds(List<JSONObject> tasks) {
|
||||
List<String> ids = new ArrayList<>();
|
||||
for (JSONObject task : tasks) {
|
||||
String uid = task.getString("userId");
|
||||
if (oConvertUtils.isNotEmpty(uid) && !ids.contains(uid)) {
|
||||
ids.add(uid);
|
||||
}
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
|
||||
private List<String> listAllAssigneeIds(List<JSONObject> taskList) {
|
||||
List<String> ids = new ArrayList<>();
|
||||
for (JSONObject task : taskList) {
|
||||
if (task == null) {
|
||||
continue;
|
||||
}
|
||||
String uid = task.getString("userId");
|
||||
if (oConvertUtils.isNotEmpty(uid) && !ids.contains(uid)) {
|
||||
ids.add(uid);
|
||||
}
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
|
||||
private boolean hasRunningOrNew(List<JSONObject> taskList) {
|
||||
for (JSONObject task : taskList) {
|
||||
if (task == null) {
|
||||
continue;
|
||||
}
|
||||
String status = task.getString("status");
|
||||
if ("RUNNING".equalsIgnoreCase(status) || "NEW".equalsIgnoreCase(status)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private boolean allCanceled(List<JSONObject> taskList) {
|
||||
for (JSONObject task : taskList) {
|
||||
if (task == null) {
|
||||
continue;
|
||||
}
|
||||
if (!"CANCELED".equalsIgnoreCase(task.getString("status"))) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private int countActiveTasks(List<JSONObject> taskList) {
|
||||
int count = 0;
|
||||
for (JSONObject task : taskList) {
|
||||
if (task == null) {
|
||||
continue;
|
||||
}
|
||||
if (!"CANCELED".equalsIgnoreCase(task.getString("status"))) {
|
||||
count++;
|
||||
}
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
private Date latestFinishTime(List<JSONObject> tasks) {
|
||||
Date latest = null;
|
||||
for (JSONObject task : tasks) {
|
||||
Date time = parseFinishTime(task.getString("finishTime"));
|
||||
if (time != null && (latest == null || time.after(latest))) {
|
||||
latest = time;
|
||||
}
|
||||
}
|
||||
return latest;
|
||||
}
|
||||
|
||||
private boolean isTraceStage(String stageKey) {
|
||||
return oConvertUtils.isNotEmpty(stageKey) && TRACE_STAGES.contains(stageKey);
|
||||
}
|
||||
|
||||
private void collectAllApproverNodes(JSONObject node, List<JSONObject> out) {
|
||||
if (node == null) {
|
||||
return;
|
||||
}
|
||||
if ("approver".equals(node.getString("type"))) {
|
||||
out.add(node);
|
||||
}
|
||||
JSONArray branches = node.getJSONArray("conditionNodes");
|
||||
if (branches != null) {
|
||||
for (int i = 0; i < branches.size(); i++) {
|
||||
Object branch = branches.get(i);
|
||||
if (branch instanceof JSONObject branchObj) {
|
||||
collectAllApproverNodes(branchObj.getJSONObject("childNode"), out);
|
||||
}
|
||||
}
|
||||
}
|
||||
collectAllApproverNodes(node.getJSONObject("childNode"), out);
|
||||
}
|
||||
|
||||
private Date parseFinishTime(String finishTime) {
|
||||
if (oConvertUtils.isEmpty(finishTime)) {
|
||||
return null;
|
||||
}
|
||||
String[] patterns = {"yyyy-MM-dd HH:mm:ss", "yyyy-MM-dd'T'HH:mm:ss", "yyyy-MM-dd'T'HH:mm:ss.SSS'Z'"};
|
||||
for (String pattern : patterns) {
|
||||
try {
|
||||
return new SimpleDateFormat(pattern).parse(finishTime.trim());
|
||||
} catch (ParseException ignored) {
|
||||
// 尝试下一种格式
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private Map<String, String> batchResolveDtUserDisplayNames(Collection<String> dtUserIds) {
|
||||
Map<String, String> result = new HashMap<>();
|
||||
if (dtUserIds == null || dtUserIds.isEmpty()) {
|
||||
return result;
|
||||
}
|
||||
List<String> ids = dtUserIds.stream().filter(oConvertUtils::isNotEmpty).distinct().collect(Collectors.toList());
|
||||
if (ids.isEmpty()) {
|
||||
return result;
|
||||
}
|
||||
String inClause = ids.stream().map(id -> "?").collect(Collectors.joining(","));
|
||||
try {
|
||||
List<Map<String, Object>> localRows = jdbcTemplate.queryForList(
|
||||
"SELECT ding_user_id, realname, username FROM sys_user "
|
||||
+ "WHERE ding_user_id IN (" + inClause + ") AND (del_flag=0 OR del_flag IS NULL)",
|
||||
ids.toArray());
|
||||
for (Map<String, Object> row : localRows) {
|
||||
String dtId = stringValue(row.get("ding_user_id"));
|
||||
if (oConvertUtils.isEmpty(dtId)) {
|
||||
continue;
|
||||
}
|
||||
result.put(dtId, pickDisplayName(stringValue(row.get("realname")), stringValue(row.get("username")), dtId));
|
||||
}
|
||||
} catch (Exception ignored) {
|
||||
// 查询失败时降级保留钉钉ID
|
||||
}
|
||||
List<String> missing = ids.stream().filter(id -> !result.containsKey(id)).collect(Collectors.toList());
|
||||
if (!missing.isEmpty()) {
|
||||
String missingIn = missing.stream().map(id -> "?").collect(Collectors.joining(","));
|
||||
try {
|
||||
List<Map<String, Object>> thirdRows = jdbcTemplate.queryForList(
|
||||
"SELECT t.third_user_id, u.realname, u.username "
|
||||
+ "FROM sys_third_account t "
|
||||
+ "JOIN sys_user u ON u.id = t.sys_user_id "
|
||||
+ "WHERE t.third_type='dingtalk' AND t.third_user_id IN (" + missingIn + ") "
|
||||
+ "AND (t.del_flag=0 OR t.del_flag IS NULL) AND (u.del_flag=0 OR u.del_flag IS NULL)",
|
||||
missing.toArray());
|
||||
for (Map<String, Object> row : thirdRows) {
|
||||
String dtId = stringValue(row.get("third_user_id"));
|
||||
if (oConvertUtils.isEmpty(dtId) || result.containsKey(dtId)) {
|
||||
continue;
|
||||
}
|
||||
result.put(dtId, pickDisplayName(stringValue(row.get("realname")), stringValue(row.get("username")), dtId));
|
||||
}
|
||||
} catch (Exception ignored) {
|
||||
// 查询失败时降级保留钉钉ID
|
||||
}
|
||||
}
|
||||
for (String id : ids) {
|
||||
result.putIfAbsent(id, id);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private String pickDisplayName(String realname, String username, String fallback) {
|
||||
if (oConvertUtils.isNotEmpty(realname)) {
|
||||
return realname;
|
||||
}
|
||||
if (oConvertUtils.isNotEmpty(username)) {
|
||||
return username;
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
private String stringValue(Object value) {
|
||||
return value == null ? null : String.valueOf(value);
|
||||
}
|
||||
//update-end---author:GHT ---date:20260608 for:【审批注册中心】按MES multiMode解析节点状态与审批人-----------
|
||||
|
||||
@Data
|
||||
@Accessors(chain = true)
|
||||
public static class StageCompletion {
|
||||
private String stage;
|
||||
private String activityId;
|
||||
private String operatorBy;
|
||||
private Date operatorTime;
|
||||
private List<String> dtUserIds;
|
||||
}
|
||||
|
||||
@Data
|
||||
@Accessors(chain = true)
|
||||
public static class NodePair {
|
||||
private int stepNo;
|
||||
private JSONObject mesNode;
|
||||
private String activityId;
|
||||
private List<JSONObject> taskList;
|
||||
}
|
||||
|
||||
@Data
|
||||
@Accessors(chain = true)
|
||||
public static class NodeTaskDecision {
|
||||
private String nodeStatus;
|
||||
private String nodeStatusText;
|
||||
private List<String> actorUserIds = new ArrayList<>();
|
||||
private Date operatorTime;
|
||||
private boolean agreed;
|
||||
private boolean refused;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,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";
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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】审批注册中心环节同步执行器-----------
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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:【审批注册中心】拒绝/终止时清空源单与痕迹操作人-----------
|
||||
}
|
||||
|
||||
@@ -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-----------
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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-----------
|
||||
}
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
package org.jeecg.modules.xslmes.approval.integration.vo;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.Data;
|
||||
import lombok.experimental.Accessors;
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 钉钉审批实例操作记录(时间轴展示)
|
||||
*/
|
||||
@Data
|
||||
@Accessors(chain = true)
|
||||
@Schema(description = "钉钉审批操作记录")
|
||||
public class DingOperationRecordVO implements Serializable {
|
||||
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
@Schema(description = "操作人钉钉userId")
|
||||
private String userId;
|
||||
|
||||
@Schema(description = "操作人姓名(本地用户映射)")
|
||||
private String userName;
|
||||
|
||||
@Schema(description = "操作时间(ISO8601)")
|
||||
private String date;
|
||||
|
||||
@Schema(description = "操作类型")
|
||||
private String type;
|
||||
|
||||
@Schema(description = "操作结果")
|
||||
private String result;
|
||||
|
||||
@Schema(description = "备注/意见")
|
||||
private String remark;
|
||||
|
||||
@Schema(description = "节点展示名称")
|
||||
private String showName;
|
||||
|
||||
@Schema(description = "节点活动ID")
|
||||
private String activityId;
|
||||
|
||||
@Schema(description = "抄送人列表")
|
||||
private List<String> ccUserIds;
|
||||
|
||||
@Schema(description = "图片URL列表")
|
||||
private List<String> images;
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
package org.jeecg.modules.xslmes.approval.integration.vo;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.Data;
|
||||
import lombok.experimental.Accessors;
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 钉钉 processForecast 预测审批节点
|
||||
*/
|
||||
@Data
|
||||
@Accessors(chain = true)
|
||||
@Schema(description = "钉钉预测审批节点")
|
||||
public class DingProcessForecastNodeVO implements Serializable {
|
||||
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
@Schema(description = "节点序号(从1开始)")
|
||||
private Integer stepNo;
|
||||
|
||||
@Schema(description = "节点活动ID")
|
||||
private String activityId;
|
||||
|
||||
@Schema(description = "节点名称")
|
||||
private String activityName;
|
||||
|
||||
@Schema(description = "MES审批流节点名称")
|
||||
private String mesNodeName;
|
||||
|
||||
@Schema(description = "节点类型(如target_select/target_approval)")
|
||||
private String activityType;
|
||||
|
||||
@Schema(description = "审批方式 NONE/AND/OR/ONE_BY_ONE")
|
||||
private String approvalMethod;
|
||||
|
||||
@Schema(description = "审批方式中文")
|
||||
private String approvalMethodText;
|
||||
|
||||
@Schema(description = "审批人钉钉userId列表")
|
||||
private List<String> actionerUserIds;
|
||||
|
||||
@Schema(description = "审批人姓名列表(本地映射)")
|
||||
private List<String> actionerNames;
|
||||
|
||||
@Schema(description = "节点状态(聚合tasks)")
|
||||
private String nodeStatus;
|
||||
|
||||
@Schema(description = "节点状态中文")
|
||||
private String nodeStatusText;
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
package org.jeecg.modules.xslmes.approval.integration.vo;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.Data;
|
||||
import lombok.experimental.Accessors;
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 钉钉审批实例 tasks 解析的审批节点结果
|
||||
*/
|
||||
@Data
|
||||
@Accessors(chain = true)
|
||||
@Schema(description = "钉钉审批节点(实例tasks解析)")
|
||||
public class DingProcessForecastVO implements Serializable {
|
||||
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
@Schema(description = "钉钉审批实例ID")
|
||||
private String processInstanceId;
|
||||
|
||||
@Schema(description = "钉钉processCode")
|
||||
private String processCode;
|
||||
|
||||
@Schema(description = "钉钉模板名称")
|
||||
private String templateName;
|
||||
|
||||
@Schema(description = "MES审批流名称")
|
||||
private String mesFlowName;
|
||||
|
||||
@Schema(description = "节点来源说明")
|
||||
private String nodeSource;
|
||||
|
||||
@Schema(description = "审批节点列表")
|
||||
private List<DingProcessForecastNodeVO> nodes;
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
package org.jeecg.modules.xslmes.approval.integration.vo;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.Data;
|
||||
import lombok.experimental.Accessors;
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 钉钉审批实例流转详情(供前端时间轴展示)
|
||||
*/
|
||||
@Data
|
||||
@Accessors(chain = true)
|
||||
@Schema(description = "钉钉审批实例流转详情")
|
||||
public class DingProcessInstanceFlowVO implements Serializable {
|
||||
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
@Schema(description = "钉钉审批实例ID")
|
||||
private String processInstanceId;
|
||||
|
||||
@Schema(description = "审批标题")
|
||||
private String title;
|
||||
|
||||
@Schema(description = "实例状态 RUNNING/COMPLETED/TERMINATED 等")
|
||||
private String status;
|
||||
|
||||
@Schema(description = "审批结果 agree/refuse 等")
|
||||
private String result;
|
||||
|
||||
@Schema(description = "业务单据ID")
|
||||
private String bizDataId;
|
||||
|
||||
@Schema(description = "业务表名")
|
||||
private String bizTable;
|
||||
|
||||
@Schema(description = "操作记录列表(时间轴)")
|
||||
private List<DingOperationRecordVO> operationRecords;
|
||||
}
|
||||
@@ -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审批配置】手动填表发起钉钉审批-----------
|
||||
}
|
||||
|
||||
@@ -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请求体-----------
|
||||
}
|
||||
@@ -13,6 +13,8 @@ import org.jeecg.modules.xslmes.approval.entity.MesXslApprovalFlow;
|
||||
import org.jeecg.modules.xslmes.approval.entity.MesXslApprovalRecord;
|
||||
import org.jeecg.modules.xslmes.approval.service.IMesXslApprovalFlowService;
|
||||
import org.jeecg.modules.xslmes.approval.service.IMesXslApprovalGateService;
|
||||
import org.jeecg.modules.xslmes.approval.integration.engine.ApprovalInstanceStageExtractor;
|
||||
import org.jeecg.modules.xslmes.approval.integration.engine.ApprovalInstanceStageExtractor.StageCompletion;
|
||||
import org.jeecg.modules.xslmes.approval.service.IMesXslApprovalRecordService;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
@@ -58,6 +60,9 @@ public class DingBpmsEventProcessor {
|
||||
@Autowired
|
||||
private JdbcTemplate jdbcTemplate;
|
||||
|
||||
@Autowired
|
||||
private ApprovalInstanceStageExtractor instanceStageExtractor;
|
||||
|
||||
// ==================== bpms_instance_change ====================
|
||||
|
||||
//update-begin---author:GHT ---date:20260604 for:【钉钉Stream回调】拉取实例详情后精准执行节点回调-----
|
||||
@@ -122,10 +127,22 @@ public class DingBpmsEventProcessor {
|
||||
}
|
||||
//update-end---author:GHT ---date:2026-06-04 for:【20260604】钉钉回调幂等去重:finishByExternalInstance条件为status=RUNNING,0行更新即终态已处理-----
|
||||
|
||||
//update-begin---author:GHT ---date:2026-06-08 for:【风险修复-R5】TERMINATED时触发fireCancelled,允许业务回滚中间态-----------
|
||||
if (ApprovalRecordConstants.STATUS_CANCELLED.equals(status)) {
|
||||
log.info("{} bpms_instance_change 终止态不触发业务回调 instanceId={}", LOG_TAG, processInstanceId);
|
||||
MesXslApprovalRecord cancelledRecord = findRecord(processInstanceId);
|
||||
if (cancelledRecord != null && oConvertUtils.isNotEmpty(cancelledRecord.getBizTable())) {
|
||||
ApprovalCallbackContext cancelCtx = buildContext(cancelledRecord, remark, null);
|
||||
logCallbackDispatch("fireCancelled", cancelCtx);
|
||||
try {
|
||||
callbackDispatcher.fireCancelled(cancelCtx);
|
||||
} catch (Exception e) {
|
||||
log.error("{} fireCancelled 失败 instanceId={}: {}", LOG_TAG, processInstanceId, e.getMessage(), e);
|
||||
}
|
||||
}
|
||||
log.info("{} bpms_instance_change 终止态处理完成 instanceId={}", LOG_TAG, processInstanceId);
|
||||
return;
|
||||
}
|
||||
//update-end---author:GHT ---date:2026-06-08 for:【风险修复-R5】TERMINATED时触发fireCancelled,允许业务回滚中间态-----------
|
||||
|
||||
// ③ 拉取完整审批实例
|
||||
MesXslApprovalRecord record = findRecord(processInstanceId);
|
||||
@@ -154,61 +171,84 @@ public class DingBpmsEventProcessor {
|
||||
LOG_TAG, processInstanceId, taskOps.size(), mesNodes.size(),
|
||||
summarizeNodeNames(mesNodes), summarizeTaskOps(taskOps));
|
||||
|
||||
ApprovalCallbackContext ctx = buildContext(record, remark);
|
||||
// token 在后续 approve/reject 分支中按实际操作人生成后注入 ctx,此处先以 null 初始化
|
||||
ApprovalCallbackContext ctx = buildContext(record, remark, null);
|
||||
|
||||
//update-begin---author:GHT ---date:2026-06-08 for:【缺陷修复-D2】用activityId替代mesNodes索引定位终态节点,支持条件分支场景-----------
|
||||
MesXslApprovalFlow flow = loadFlow(record.getFlowId());
|
||||
String flowConfig = (flow != null) ? flow.getFlowConfig() : null;
|
||||
|
||||
if (ApprovalRecordConstants.STATUS_APPROVED.equals(status)) {
|
||||
if (!mesNodes.isEmpty() && !taskOps.isEmpty()) {
|
||||
JSONObject lastNode = null;
|
||||
String lastDtUserId = null;
|
||||
if (!taskOps.isEmpty()) {
|
||||
JSONObject lastOp = taskOps.get(taskOps.size() - 1);
|
||||
String lastDtUserId = lastOp.getString("userId");
|
||||
String token = workflowService.generateTokenByDtUserId(lastDtUserId);
|
||||
JSONObject lastNode = mesNodes.get(mesNodes.size() - 1);
|
||||
log.info("{} 终态通过:最后节点 nodeId={} nodeName={} dtUserId={} tokenGenerated={}",
|
||||
LOG_TAG, lastNode.getString("id"), lastNode.getString("name"),
|
||||
lastDtUserId, oConvertUtils.isNotEmpty(token));
|
||||
lastDtUserId = lastOp.getString("userId");
|
||||
String lastActivityId = lastOp.getString("activityId");
|
||||
// 优先用 activityId 精确定位(修复缺陷2)
|
||||
if (oConvertUtils.isNotEmpty(lastActivityId) && oConvertUtils.isNotEmpty(flowConfig)) {
|
||||
lastNode = findNodeByActivityId(flowConfig, lastActivityId);
|
||||
}
|
||||
// 降级:无 activityId 时回退到索引
|
||||
if (lastNode == null && !mesNodes.isEmpty()) {
|
||||
lastNode = mesNodes.get(mesNodes.size() - 1);
|
||||
}
|
||||
} else if (!mesNodes.isEmpty()) {
|
||||
lastNode = mesNodes.get(mesNodes.size() - 1);
|
||||
}
|
||||
//update-begin---author:GHT ---date:20260605 for:【XSLMES-20260605-K8R2】钉钉终态回调补充节点信息供集成引擎匹配环节-----------
|
||||
if (!mesNodes.isEmpty()) {
|
||||
JSONObject lastNode = mesNodes.get(mesNodes.size() - 1);
|
||||
String token = workflowService.generateTokenByDtUserId(lastDtUserId);
|
||||
ctx.setToken(token);
|
||||
if (lastNode != null) {
|
||||
ctx.setNodeId(lastNode.getString("id")).setNodeName(lastNode.getString("name"));
|
||||
JSONObject nodeProps = lastNode.getJSONObject("props");
|
||||
if (nodeProps != null) ctx.setStageKey(nodeProps.getString("stageKey"));
|
||||
}
|
||||
//update-end---author:GHT ---date:20260605 for:【XSLMES-20260605-K8R2】钉钉终态回调补充节点信息供集成引擎匹配环节-----------
|
||||
log.info("{} 终态通过:最后节点 nodeId={} nodeName={} dtUserId={} tokenGenerated={}",
|
||||
LOG_TAG, ctx.getNodeId(), ctx.getNodeName(), lastDtUserId, oConvertUtils.isNotEmpty(token));
|
||||
//update-begin---author:GHT ---date:20260608 for:【缺陷修复-会签集成-R7】bpms_instance_change先于bpms_task_change处理时,R6会跳过最后节点的节点回调;此处补偿触发,确保ON_NODE_APPROVE集成方案正常执行,幂等key(recordId+actionId)保证双路只执行一次-----------
|
||||
logCallbackDispatch("fireNodeApproved (终态兜底-最终节点)", ctx);
|
||||
callbackDispatcher.fireNodeApproved(ctx);
|
||||
//update-end---author:GHT ---date:20260608 for:【缺陷修复-会签集成-R7】bpms_instance_change先于bpms_task_change处理时,R6会跳过最后节点的节点回调;此处补偿触发,确保ON_NODE_APPROVE集成方案正常执行,幂等key(recordId+actionId)保证双路只执行一次-----------
|
||||
logCallbackDispatch("fireApproved", ctx);
|
||||
callbackDispatcher.fireApproved(ctx);
|
||||
|
||||
} else {
|
||||
JSONObject refuseOp = findRefuseOp(taskOps);
|
||||
JSONObject refuseNode = null;
|
||||
String refuseDtUserId = null;
|
||||
if (refuseOp != null) {
|
||||
int refuseIndex = taskOps.indexOf(refuseOp);
|
||||
refuseDtUserId = refuseOp.getString("userId");
|
||||
String refuseActivityId = refuseOp.getString("activityId");
|
||||
// 优先用 activityId 精确定位(修复缺陷2)
|
||||
if (oConvertUtils.isNotEmpty(refuseActivityId) && oConvertUtils.isNotEmpty(flowConfig)) {
|
||||
refuseNode = findNodeByActivityId(flowConfig, refuseActivityId);
|
||||
}
|
||||
// 降级:无 activityId 时回退到索引
|
||||
if (refuseNode == null) {
|
||||
int refuseIndex = taskOps.indexOf(refuseOp);
|
||||
if (refuseIndex >= 0 && refuseIndex < mesNodes.size()) {
|
||||
refuseNode = mesNodes.get(refuseIndex);
|
||||
}
|
||||
}
|
||||
boolean bizAtOrigin = isBizAtOriginStatus(record);
|
||||
String currentBizStatus = readBizStatus(record);
|
||||
log.info("{} 终态驳回:refuseIndex={} bizAtOrigin={} originStatus={} currentBizStatus={} refuseOp={}",
|
||||
LOG_TAG, refuseIndex, bizAtOrigin, record.getOriginStatus(), currentBizStatus,
|
||||
refuseOp.toJSONString());
|
||||
if (!bizAtOrigin && refuseIndex < mesNodes.size()) {
|
||||
String dtUserId = refuseOp.getString("userId");
|
||||
String token = workflowService.generateTokenByDtUserId(dtUserId);
|
||||
JSONObject refuseNode = mesNodes.get(refuseIndex);
|
||||
log.info("{} 终态驳回:业务已推进,触发 onReject 集成 nodeId={} nodeName={} tokenGenerated={}",
|
||||
LOG_TAG, refuseNode.getString("id"), refuseNode.getString("name"),
|
||||
oConvertUtils.isNotEmpty(token));
|
||||
} else {
|
||||
log.info("{} 终态驳回:跳过业务 onReject(单据仍在发起前状态) instanceId={}",
|
||||
LOG_TAG, processInstanceId);
|
||||
}
|
||||
log.info("{} 终态驳回:refuseActivityId={} refuseNodeId={} bizAtOrigin={} originStatus={} currentBizStatus={}",
|
||||
LOG_TAG, refuseActivityId, refuseNode != null ? refuseNode.getString("id") : null,
|
||||
bizAtOrigin, record.getOriginStatus(), currentBizStatus);
|
||||
} else {
|
||||
log.info("{} 终态驳回:operationRecords 中未找到 REFUSE 记录 instanceId={}",
|
||||
LOG_TAG, processInstanceId);
|
||||
log.info("{} 终态驳回:operationRecords 中未找到 REFUSE 记录 instanceId={}", LOG_TAG, processInstanceId);
|
||||
}
|
||||
if (!mesNodes.isEmpty() && refuseOp != null) {
|
||||
int refuseIndex = taskOps.indexOf(refuseOp);
|
||||
if (refuseIndex >= 0 && refuseIndex < mesNodes.size()) {
|
||||
JSONObject refuseNode = mesNodes.get(refuseIndex);
|
||||
ctx.setNodeId(refuseNode.getString("id")).setNodeName(refuseNode.getString("name"));
|
||||
}
|
||||
String token = workflowService.generateTokenByDtUserId(refuseDtUserId);
|
||||
ctx.setToken(token);
|
||||
if (refuseNode != null) {
|
||||
ctx.setNodeId(refuseNode.getString("id")).setNodeName(refuseNode.getString("name"));
|
||||
JSONObject nodeProps = refuseNode.getJSONObject("props");
|
||||
if (nodeProps != null) ctx.setStageKey(nodeProps.getString("stageKey"));
|
||||
}
|
||||
logCallbackDispatch("fireRejected", ctx);
|
||||
callbackDispatcher.fireRejected(ctx);
|
||||
}
|
||||
//update-end---author:GHT ---date:2026-06-08 for:【缺陷修复-D2】用activityId替代mesNodes索引定位终态节点,支持条件分支场景-----------
|
||||
|
||||
log.info("{} bpms_instance_change 完成 instanceId={} bizTable={} bizDataId={} mesStatus={}",
|
||||
LOG_TAG, processInstanceId, record.getBizTable(), record.getBizDataId(), status);
|
||||
@@ -226,14 +266,22 @@ public class DingBpmsEventProcessor {
|
||||
String processInstanceId = data.getString("processInstanceId");
|
||||
String type = data.getString("type");
|
||||
String result = data.getString("result");
|
||||
String actionerDtUserId = data.getString("actionerUserId");
|
||||
//update-begin---author:GHT ---date:20260608 for:【钉钉Stream回调】兼容staffId/actualActionerId字段(Stream事件无actionerUserId)-----------
|
||||
String actionerDtUserId = resolveActionerDtUserId(data);
|
||||
String eventActivityId = data.getString("activityId");
|
||||
//update-end---author:GHT ---date:20260608 for:【钉钉Stream回调】兼容staffId/actualActionerId字段(Stream事件无actionerUserId)-----------
|
||||
|
||||
log.info("{} bpms_task_change 入参 instanceId={} type={} result={} actionerUserId={} payload={}",
|
||||
LOG_TAG, processInstanceId, type, result, actionerDtUserId, data.toJSONString());
|
||||
log.info("{} bpms_task_change 入参 instanceId={} type={} result={} actionerUserId={} activityId={} payload={}",
|
||||
LOG_TAG, processInstanceId, type, result, actionerDtUserId, eventActivityId, data.toJSONString());
|
||||
|
||||
if (!"finish".equals(type) || !"agree".equals(result)) {
|
||||
log.info("{} bpms_task_change 跳过:非节点通过(finish+agree) type={} result={} instanceId={}",
|
||||
LOG_TAG, type, result, processInstanceId);
|
||||
if (!"finish".equals(type)) {
|
||||
log.info("{} bpms_task_change 跳过:type={} 非节点结束事件(finish) instanceId={}",
|
||||
LOG_TAG, type, processInstanceId);
|
||||
return;
|
||||
}
|
||||
if (!"agree".equals(result)) {
|
||||
log.info("{} bpms_task_change 跳过:result={} 非同意,refuse/redirect 由 bpms_instance_change 处理 instanceId={}",
|
||||
LOG_TAG, result, processInstanceId);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -248,6 +296,14 @@ public class DingBpmsEventProcessor {
|
||||
return;
|
||||
}
|
||||
|
||||
//update-begin---author:GHT ---date:2026-06-08 for:【风险修复-R6】bpms_instance_change可能已将台账置为终态;此时ON_NODE_APPROVE由onInstanceChange中R7补偿触发(fireNodeApproved),此处安全跳过避免重复。-----------
|
||||
if (!ApprovalRecordConstants.STATUS_RUNNING.equals(record.getStatus())) {
|
||||
log.info("{} bpms_task_change 跳过:台账已是终态 status={} instanceId={} recordId={} (ON_NODE_APPROVE已由onInstanceChange补偿触发)",
|
||||
LOG_TAG, record.getStatus(), processInstanceId, record.getId());
|
||||
return;
|
||||
}
|
||||
//update-end---author:GHT ---date:2026-06-08 for:【风险修复-R6】bpms_instance_change可能已将台账置为终态;此时ON_NODE_APPROVE由onInstanceChange中R7补偿触发(fireNodeApproved),此处安全跳过避免重复。-----------
|
||||
|
||||
log.info("{} 台账命中 recordId={} bizTable={} bizDataId={} flowId={} processedOpCount={} currentBizStatus={}",
|
||||
LOG_TAG, record.getId(), record.getBizTable(), record.getBizDataId(),
|
||||
record.getFlowId(), record.getProcessedOpCount(), readBizStatus(record));
|
||||
@@ -258,6 +314,14 @@ public class DingBpmsEventProcessor {
|
||||
return;
|
||||
}
|
||||
|
||||
//update-begin---author:GHT ---date:2026-06-08 for:【缺陷修复-D4】检测审批退回(REDIRECT_PROCESS),记录警告供排查(退回后重审节点受幂等保护,属已知限制)-----------
|
||||
if (workflowService.hasRedirectProcess(instance)) {
|
||||
log.warn("{} bpms_task_change 检测到审批退回(REDIRECT_PROCESS) instanceId={}。" +
|
||||
"activityId方案已正确定位节点;退回后重审同一节点会被tryMarkNodeProcessed幂等拦截,属已知限制。",
|
||||
LOG_TAG, processInstanceId);
|
||||
}
|
||||
//update-end---author:GHT ---date:2026-06-08 for:【缺陷修复-D4】检测审批退回(REDIRECT_PROCESS),记录警告供排查-----------
|
||||
|
||||
List<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) {
|
||||
|
||||
@@ -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 模式下,若仅该用户的任务仍为 RUNNING(API 延迟),
|
||||
* 而其他参与人均已 AGREE,则视节点为已完成,避免竞态漏触发集成方案。
|
||||
*
|
||||
* @param actionerDtUserId 触发本次 bpms_task_change 的审批人钉钉 userId
|
||||
* @return 节点已完成的最新实例快照;未完成返回 null
|
||||
*/
|
||||
public JSONObject resolveInstanceWhenNodeComplete(String processInstanceId,
|
||||
JSONObject instance,
|
||||
String activityId,
|
||||
JSONObject mesNode,
|
||||
String actionerDtUserId) {
|
||||
if (isNodeCompleteForCallback(instance, activityId, mesNode, actionerDtUserId)) {
|
||||
return instance;
|
||||
}
|
||||
JSONObject refreshed = getProcessInstance(processInstanceId);
|
||||
if (refreshed != null && isNodeCompleteForCallback(refreshed, activityId, mesNode, actionerDtUserId)) {
|
||||
log.info("{} 会签节点完成判定:刷新实例后通过 activityId={} instanceId={}",
|
||||
LOG_TAG, activityId, processInstanceId);
|
||||
return refreshed;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
//update-end---author:GHT ---date:20260608 for:【缺陷修复-会签集成】新增actionerDtUserId参数,修复AND模式API延迟导致最后一个会签人任务仍RUNNING时节点误判未完成-----------
|
||||
|
||||
//update-begin---author:GHT ---date:20260608 for:【缺陷修复-会签集成】重构节点完成判定:以tasks全员COMPLETED+AGREE为主判定,去除复杂mode链路-----------
|
||||
/**
|
||||
* 节点是否已完成(可触发集成方案):
|
||||
* <ol>
|
||||
* <li>主判定:同 activityId 下所有非取消任务均为 COMPLETED+AGREE(会签全员通过 / 或签实际通过人已通过)</li>
|
||||
* <li>API滞后兜底:当前审批人任务仍 RUNNING,其余均已 AGREE(AND最后一人事件先于API更新到达)</li>
|
||||
* <li>或签/单人兜底:非AND/ONE_BY_ONE模式时,任意一人AGREE即完成(其他人取消可能短暂延迟)</li>
|
||||
* </ol>
|
||||
*/
|
||||
private boolean isNodeCompleteForCallback(JSONObject instance, String activityId, JSONObject mesNode,
|
||||
String actionerDtUserId) {
|
||||
if (instance == null || oConvertUtils.isEmpty(activityId)) {
|
||||
return false;
|
||||
}
|
||||
List<JSONObject> taskList = filterTasksByActivity(instance, activityId);
|
||||
if (taskList.isEmpty()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 1. 主判定:所有非取消任务均已 COMPLETED+AGREE
|
||||
if (allNonCanceledTasksCompletedAgree(taskList)) {
|
||||
log.info("{} 节点完成判定:全员COMPLETED+AGREE activityId={} taskCount={}",
|
||||
LOG_TAG, activityId, taskList.size());
|
||||
return true;
|
||||
}
|
||||
|
||||
// 2. API滞后兜底:当前审批人任务仍RUNNING,其余均已AGREE
|
||||
if (oConvertUtils.isNotEmpty(actionerDtUserId) && isVirtuallyCompleteByActioner(taskList, actionerDtUserId)) {
|
||||
log.info("{} 节点完成判定:API滞后虚拟完成 activityId={} actionerDtUserId={}",
|
||||
LOG_TAG, activityId, actionerDtUserId);
|
||||
return true;
|
||||
}
|
||||
|
||||
// 3. 或签/单人兜底:任意一人AGREE即可(其他人取消可能短暂延迟,刷新实例后通常能命中主判定)
|
||||
if (mesNode != null && instanceStageExtractor != null) {
|
||||
String method = instanceStageExtractor.resolveApprovalMethod(mesNode);
|
||||
if (!"AND".equalsIgnoreCase(method) && !"ONE_BY_ONE".equalsIgnoreCase(method)) {
|
||||
boolean anyAgree = taskList.stream().anyMatch(t -> t != null
|
||||
&& "COMPLETED".equalsIgnoreCase(t.getString("status"))
|
||||
&& "AGREE".equalsIgnoreCase(t.getString("result")));
|
||||
if (anyAgree) {
|
||||
log.info("{} 节点完成判定:OR/NONE任一AGREE activityId={} method={}",
|
||||
LOG_TAG, activityId, method);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/** 同 activityId 下所有非取消(CANCELED)任务是否均已 COMPLETED+AGREE */
|
||||
private boolean allNonCanceledTasksCompletedAgree(List<JSONObject> taskList) {
|
||||
int activeCount = 0;
|
||||
int completedAgreeCount = 0;
|
||||
for (JSONObject task : taskList) {
|
||||
if (task == null) continue;
|
||||
if ("CANCELED".equalsIgnoreCase(task.getString("status"))) continue;
|
||||
activeCount++;
|
||||
if ("COMPLETED".equalsIgnoreCase(task.getString("status"))
|
||||
&& "AGREE".equalsIgnoreCase(task.getString("result"))) {
|
||||
completedAgreeCount++;
|
||||
}
|
||||
}
|
||||
return activeCount > 0 && completedAgreeCount == activeCount;
|
||||
}
|
||||
|
||||
/**
|
||||
* API滞后竞态补偿:当前审批人任务仍 RUNNING,其余均已 COMPLETED+AGREE,视节点为已完成。
|
||||
* 若存在其他人(非当前审批人)的 RUNNING/NEW 任务,立即返回 false(节点确实未完成)。
|
||||
*/
|
||||
private boolean isVirtuallyCompleteByActioner(List<JSONObject> taskList, String actionerDtUserId) {
|
||||
int activeCount = 0;
|
||||
int agreeCount = 0;
|
||||
int runningByActioner = 0;
|
||||
for (JSONObject task : taskList) {
|
||||
if (task == null) continue;
|
||||
String status = task.getString("status");
|
||||
if ("CANCELED".equalsIgnoreCase(status)) continue;
|
||||
activeCount++;
|
||||
String result = task.getString("result");
|
||||
if ("COMPLETED".equalsIgnoreCase(status) && "AGREE".equalsIgnoreCase(result)) {
|
||||
agreeCount++;
|
||||
} else if ("RUNNING".equalsIgnoreCase(status) || "NEW".equalsIgnoreCase(status)) {
|
||||
if (actionerDtUserId.equals(task.getString("userId"))) {
|
||||
runningByActioner++;
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
return activeCount > 0 && runningByActioner > 0 && (agreeCount + runningByActioner) >= activeCount;
|
||||
}
|
||||
//update-end---author:GHT ---date:20260608 for:【缺陷修复-会签集成】重构节点完成判定:以tasks全员COMPLETED+AGREE为主判定,去除复杂mode链路-----------
|
||||
|
||||
private List<JSONObject> filterTasksByActivity(JSONObject instance, String activityId) {
|
||||
List<JSONObject> result = new ArrayList<>();
|
||||
if (instance == null || oConvertUtils.isEmpty(activityId)) {
|
||||
return result;
|
||||
}
|
||||
JSONArray tasks = instance.getJSONArray("tasks");
|
||||
if (tasks == null) {
|
||||
return result;
|
||||
}
|
||||
for (int i = 0; i < tasks.size(); i++) {
|
||||
JSONObject task = tasks.getJSONObject(i);
|
||||
if (task != null && activityId.equals(task.getString("activityId"))) {
|
||||
result.add(task);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
//update-end---author:GHT ---date:20260608 for:【钉钉Stream回调】会签节点完成判定增强(刷新实例+multiMode兜底)-----------
|
||||
|
||||
/**
|
||||
* 检查实例 operationRecords 中是否存在 REDIRECT_PROCESS(审批退回)记录。
|
||||
* <p>
|
||||
* 存在退回记录时,nodeActivityMap 的 completionAt 计数已失效。
|
||||
* 当前实现在检测到退回后记录警告日志;采用 activityId 方案时退回对节点定位无影响,
|
||||
* 但对幂等性(tryMarkNodeProcessed)有影响:退回后重新审批的节点会被幂等拦截。
|
||||
*
|
||||
* @return true=存在审批退回记录
|
||||
*/
|
||||
public boolean hasRedirectProcess(JSONObject instanceResult) {
|
||||
if (instanceResult == null) return false;
|
||||
JSONArray records = instanceResult.getJSONArray("operationRecords");
|
||||
if (records == null) return false;
|
||||
for (int i = 0; i < records.size(); i++) {
|
||||
JSONObject rec = records.getJSONObject(i);
|
||||
if (rec != null && "REDIRECT_PROCESS".equals(rec.getString("type"))) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
//update-end---author:GHT ---date:2026-06-08 for:【缺陷修复-D2/D3/D4】基于activityId精准定位节点,替代completionAt计数方案-----------
|
||||
|
||||
/** 生成 admin 系统 token,用于钉钉用户未绑定 MES 账号时的兜底 */
|
||||
public String generateAdminToken() {
|
||||
try {
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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" 表示无需提示
|
||||
|
||||
@@ -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功能-----------
|
||||
}
|
||||
|
||||
@@ -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幂等边界)';
|
||||
@@ -0,0 +1,2 @@
|
||||
-- 用户表新增钉钉用户ID字段
|
||||
ALTER TABLE sys_user ADD COLUMN ding_user_id VARCHAR(100) DEFAULT NULL COMMENT '钉钉用户ID';
|
||||
@@ -0,0 +1,31 @@
|
||||
-- 【混炼示方】主表新增状态字段,复用配合示方状态字典
|
||||
-- author: cursor date: 2026-06-08 for:【XSLMES-20260608-A01】
|
||||
SET NAMES utf8mb4;
|
||||
SET @db = DATABASE();
|
||||
|
||||
SET @sql = IF(
|
||||
(SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = @db AND TABLE_NAME = 'mes_xsl_mixing_spec' AND COLUMN_NAME = 'status') = 0,
|
||||
'ALTER TABLE `mes_xsl_mixing_spec` ADD COLUMN `status` varchar(32) DEFAULT ''compile'' COMMENT ''状态(字典xslmes_formula_spec_status)'' AFTER `change_date`',
|
||||
'SELECT 1'
|
||||
);
|
||||
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
|
||||
|
||||
-- 按已有审批痕迹回填历史数据状态
|
||||
UPDATE `mes_xsl_mixing_spec` SET `status` = 'obsolete' WHERE `del_flag` = 1;
|
||||
UPDATE `mes_xsl_mixing_spec` SET `status` = 'recognition_pass' WHERE `del_flag` = 0 AND `approve_time` IS NOT NULL;
|
||||
UPDATE `mes_xsl_mixing_spec` SET `status` = 'review_pass' WHERE `del_flag` = 0 AND `approve_time` IS NULL AND `audit_time` IS NOT NULL;
|
||||
UPDATE `mes_xsl_mixing_spec` SET `status` = 'submit' WHERE `del_flag` = 0 AND `approve_time` IS NULL AND `audit_time` IS NULL AND `proofread_time` IS NOT NULL;
|
||||
|
||||
-- 混炼示方注册中心默认开启三环节并绑定 status 字段
|
||||
UPDATE `mes_xsl_biz_doc_registry`
|
||||
SET `enabled_stages` = 'proofread,audit,approve',
|
||||
`status_field` = 'status',
|
||||
`proofread_by_field` = 'proofread_by',
|
||||
`proofread_time_field` = 'proofread_time',
|
||||
`audit_by_field` = 'audit_by',
|
||||
`audit_time_field` = 'audit_time',
|
||||
`approve_by_field` = 'approve_by',
|
||||
`approve_time_field` = 'approve_time',
|
||||
`update_by` = 'admin',
|
||||
`update_time` = NOW()
|
||||
WHERE `doc_code` = 'mixing_spec' AND `del_flag` = 0;
|
||||
@@ -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 for:【XSLMES-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 for:【XSLMES-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();
|
||||
|
||||
@@ -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接口-----------
|
||||
|
||||
@@ -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[] = [
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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 } },
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user