新增钉钉 Stream SDK 依赖,支持无 HTTP 上下文的后台线程显式传入 token 进行审批回调。同时,完善了 MES 审批台账功能,新增审批记录同步、批量发起审批时的门禁与台账写入逻辑,增强了系统的审批流管理能力。

This commit is contained in:
geht
2026-06-05 10:44:30 +08:00
parent 4785c55e52
commit fc4e3211ad
73 changed files with 5225 additions and 240 deletions

View File

@@ -677,4 +677,34 @@ jeecgboot-vue3/src/views/xslmes/mesXslRubberSmallLockReason/MesXslRubberSmallLoc
jeecgboot-vue3/src/views/xslmes/mesXslRubberSmallLockLog/MesXslRubberSmallLockLog.data.ts
jeecgboot-vue3/src/views/xslmes/mesXslRubberSmallLockLog/MesXslRubberSmallLockLog.api.ts
jeecgboot-vue3/src/views/xslmes/mesXslRubberSmallLockLog/MesXslRubberSmallLockLogList.vue
jeecgboot-vue3/src/views/xslmes/mesXslRubberSmallLockLog/components/MesXslRubberSmallLockLogModal.vue
-- author:GHT---date:20260604--for: 【QH-MES审批台账】跨 MES/钉钉 统一审批门禁台账 -----
jeecg-boot/jeecg-module-system/jeecg-system-start/src/main/resources/flyway/sql/mysql/V3.9.2_125__mes_xsl_approval_record.sql
jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/constant/ApprovalRecordConstants.java
jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/entity/MesXslApprovalRecord.java
jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/mapper/MesXslApprovalRecordMapper.java
jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/service/IMesXslApprovalRecordService.java
jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/service/impl/MesXslApprovalRecordServiceImpl.java
jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/service/IMesXslApprovalGateService.java
jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/service/impl/MesXslApprovalGateServiceImpl.java
jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/vo/ApprovalGateVo.java
jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/controller/MesXslApprovalGateController.java
jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/controller/MesXslApprovalLaunchController.java
jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/service/impl/MesXslApprovalHandleServiceImpl.java
jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/dingtalk/controller/MesXslDingProcessTplController.java
jeecgboot-vue3/src/views/approval/gate/approvalGate.api.ts
jeecgboot-vue3/src/components/DingTplLaunch/DingBindLaunchModal.vue
jeecgboot-vue3/src/components/DingTplLaunch/index.vue
-- author:GHT---date:20260604--for: 【QH-MES审批台账】MESToDing审批配置下增加审批台账菜单与列表页 -----
jeecg-boot/jeecg-module-system/jeecg-system-start/src/main/resources/flyway/sql/mysql/V3.9.2_126__mes_xsl_approval_record_menu.sql
jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/controller/MesXslApprovalRecordController.java
jeecgboot-vue3/src/views/xslmes/approval/mesXslApprovalRecord/MesXslApprovalRecord.api.ts
jeecgboot-vue3/src/views/xslmes/approval/mesXslApprovalRecord/MesXslApprovalRecord.data.ts
jeecgboot-vue3/src/views/xslmes/approval/mesXslApprovalRecord/MesXslApprovalRecordList.vue
jeecgboot-vue3/src/views/xslmes/approval/mesXslApprovalRecord/components/MesXslApprovalRecordDetailModal.vue
-- author:GHT---date:20260604--for: 【钉钉Stream回调】Stream模式接收钉钉审批结果并回写MES审批台账 -----
jeecg-boot/jeecg-module-system/jeecg-system-biz/src/main/java/org/jeecg/modules/system/service/impl/ThirdAppDingtalkServiceImpl.java
jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/dingtalk/stream/DingBpmsEventProcessor.java
jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/dingtalk/stream/DingTalkStreamClient.java

View File

@@ -19,6 +19,12 @@
<artifactId>jeecg-boot-base-core</artifactId>
<version>${jeecgboot.version}</version>
</dependency>
<!-- 钉钉 Stream SDK: 官方 Stream 模式事件订阅,无需公网回调地址 GHT 20260604 -->
<dependency>
<groupId>com.dingtalk.open</groupId>
<artifactId>dingtalk-stream</artifactId>
<version>1.3.12</version>
</dependency>
<!-- 复用打印模板模块打印机枚举、业务绑定、PDF 提交队列 -->
<dependency>
<groupId>org.jeecgframework.boot3</groupId>

View File

@@ -75,6 +75,19 @@ public class ApprovalActionHttpExecutor {
if (node == null || inst == null) {
return;
}
String token = currentToken();
run(node, phase, inst.getBizDataId(), token);
}
//update-begin---author:GHT ---date:20260604 for【钉钉Stream回调】钉钉后台线程无HTTP上下文支持显式传入token-----
/**
* 以显式 token 执行节点回调配置的接口(供无 HTTP 上下文的后台线程使用,如钉钉 Stream 回调)。
* 当 token 为空时降级跳过(与原有行为一致)。
*/
public void run(JSONObject node, String phase, String bizDataId, String token) {
if (node == null || oConvertUtils.isEmpty(bizDataId)) {
return;
}
JSONObject propsObj = node.getJSONObject("props");
if (propsObj == null) {
return;
@@ -87,7 +100,6 @@ public class ApprovalActionHttpExecutor {
if (actions == null || actions.isEmpty()) {
return;
}
String token = currentToken();
for (int i = 0; i < actions.size(); i++) {
JSONObject action = actions.getJSONObject(i);
if (action == null) {
@@ -98,14 +110,14 @@ public class ApprovalActionHttpExecutor {
continue;
}
if (oConvertUtils.isEmpty(token)) {
// 无登录态(自动流转/无人值守) -> 降级跳过
log.warn("[审批回调] 无当前处理人登录态,跳过接口调用 phase={}, url={}, bizId={}", phase, url, inst.getBizDataId());
log.warn("[审批回调] 无登录态,跳过接口调用 phase={}, url={}, bizId={}", phase, url, bizDataId);
continue;
}
String method = oConvertUtils.getString(action.getString("method"), "POST").toUpperCase();
invoke(method, url, action.getJSONObject("body"), inst.getBizDataId(), token);
invoke(method, url, action.getJSONObject("body"), bizDataId, token);
}
}
//update-end---author:GHT ---date:20260604 for【钉钉Stream回调】钉钉后台线程无HTTP上下文支持显式传入token-----
//update-begin---author:GHT ---date:2026-05-29 for【QH-MES审批流设计】驳回统一回退按表注解自动调用业务接口-----------
/**

View File

@@ -0,0 +1,34 @@
package org.jeecg.modules.xslmes.approval.constant;
/**
* 审批台账常量
*
* @author GHT
* @date 2026-06-04 for【QH-MES审批台账】跨通道审批门禁
*/
public final class ApprovalRecordConstants {
private ApprovalRecordConstants() {
}
/** MES 审批通道 */
public static final String CHANNEL_MES = "MES";
/** 钉钉审批通道 */
public static final String CHANNEL_DINGTALK = "DINGTALK";
/** 流转中 */
public static final String STATUS_RUNNING = "0";
/** 审批通过 */
public static final String STATUS_APPROVED = "1";
/** 审批拒绝 */
public static final String STATUS_REJECTED = "2";
/** 已撤销 */
public static final String STATUS_CANCELLED = "3";
/** 发起失败 */
public static final String STATUS_LAUNCH_FAILED = "4";
}

View File

@@ -0,0 +1,74 @@
package org.jeecg.modules.xslmes.approval.controller;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.Data;
import lombok.extern.slf4j.Slf4j;
import org.jeecg.common.api.vo.Result;
import org.jeecg.common.util.oConvertUtils;
import org.jeecg.modules.xslmes.approval.entity.MesXslApprovalRecord;
import org.jeecg.modules.xslmes.approval.service.IMesXslApprovalGateService;
import org.jeecg.modules.xslmes.approval.vo.ApprovalGateVo;
import org.jeecg.modules.xslmes.common.MesXslTenantUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.*;
import java.util.List;
/**
* 审批门禁 API
*
* @author GHT
* @date 2026-06-04 for【QH-MES审批台账】跨通道审批门禁
*/
@Tag(name = "MES审批门禁")
@RestController
@RequestMapping("/xslmes/approvalGate")
@Slf4j
public class MesXslApprovalGateController {
@Autowired
private IMesXslApprovalGateService approvalGateService;
@Operation(summary = "检查是否允许发起审批")
@GetMapping("/canLaunch")
public Result<ApprovalGateVo> canLaunch(
@RequestParam("bizTable") String bizTable,
@RequestParam("bizDataId") String bizDataId) {
if (oConvertUtils.isEmpty(bizTable) || oConvertUtils.isEmpty(bizDataId)) {
return Result.error("bizTable 与 bizDataId 不能为空");
}
Integer tenantId = MesXslTenantUtils.resolveTenantId(null);
ApprovalGateVo vo = approvalGateService.checkCanLaunch(bizTable.trim(), bizDataId.trim(), tenantId);
return Result.OK(vo);
}
@Operation(summary = "批量检查是否允许发起审批")
@PostMapping("/canLaunchBatch")
public Result<List<ApprovalGateVo>> canLaunchBatch(@RequestBody CanLaunchBatchRequest req) {
if (req == null || oConvertUtils.isEmpty(req.getBizTable()) || req.getBizDataIds() == null || req.getBizDataIds().isEmpty()) {
return Result.error("bizTable 与 bizDataIds 不能为空");
}
Integer tenantId = MesXslTenantUtils.resolveTenantId(null);
List<ApprovalGateVo> list = approvalGateService.checkCanLaunchBatch(req.getBizTable().trim(), req.getBizDataIds(), tenantId);
return Result.OK(list);
}
@Operation(summary = "查询业务单据审批台账历史")
@GetMapping("/history")
public Result<List<MesXslApprovalRecord>> history(
@RequestParam("bizTable") String bizTable,
@RequestParam("bizDataId") String bizDataId) {
if (oConvertUtils.isEmpty(bizTable) || oConvertUtils.isEmpty(bizDataId)) {
return Result.error("bizTable 与 bizDataId 不能为空");
}
Integer tenantId = MesXslTenantUtils.resolveTenantId(null);
return Result.OK(approvalGateService.listHistory(bizTable.trim(), bizDataId.trim(), tenantId));
}
@Data
public static class CanLaunchBatchRequest {
private String bizTable;
private List<String> bizDataIds;
}
}

View File

@@ -1,6 +1,5 @@
package org.jeecg.modules.xslmes.approval.controller;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
@@ -12,8 +11,10 @@ import org.jeecg.common.util.oConvertUtils;
import org.jeecg.modules.xslmes.approval.entity.MesXslApprovalFlow;
import org.jeecg.modules.xslmes.approval.entity.MesXslApprovalInstance;
import org.jeecg.modules.xslmes.approval.service.IMesXslApprovalFlowService;
import org.jeecg.modules.xslmes.approval.service.IMesXslApprovalGateService;
import org.jeecg.modules.xslmes.approval.service.IMesXslApprovalHandleService;
import org.jeecg.modules.xslmes.approval.service.IMesXslApprovalInstanceService;
import org.jeecg.modules.xslmes.approval.vo.ApprovalGateVo;
import org.jeecg.modules.xslmes.common.MesXslTenantUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.jdbc.core.JdbcTemplate;
@@ -52,6 +53,11 @@ public class MesXslApprovalLaunchController {
private IMesXslApprovalHandleService approvalHandleService;
//update-end---author:GHT ---date:2026-05-29 for【QH-MES审批流设计】发起改用流转引擎进入首节点-----
//update-begin---author:GHT ---date:20260604 for【QH-MES审批台账】发起审批统一门禁与台账写入-----
@Autowired
private IMesXslApprovalGateService approvalGateService;
//update-end---author:GHT ---date:20260604 for【QH-MES审批台账】发起审批统一门禁与台账写入-----
@Autowired
private JdbcTemplate jdbcTemplate;
@@ -166,22 +172,22 @@ public class MesXslApprovalLaunchController {
return Result.error("该审批流未发布,无法发起");
}
//update-begin---author:GHT ---date:2026-05-29 for【QH-MES审批流完善】防止同一单据重复发起审批-----
long active = approvalInstanceService.count(new LambdaQueryWrapper<MesXslApprovalInstance>()
.eq(MesXslApprovalInstance::getBizTable, flow.getBizTable())
.eq(MesXslApprovalInstance::getBizDataId, bizDataId)
.eq(MesXslApprovalInstance::getStatus, "0"));
if (active > 0) {
return Result.error("该单据已有审批中的流程,请勿重复发起");
}
//update-end---author:GHT ---date:2026-05-29 for【QH-MES审批流完善】防止同一单据重复发起审批-----
LoginUser loginUser = (LoginUser) SecurityUtils.getSubject().getPrincipal();
//update-begin---author:GHT ---date:2026-05-29 for【QH-MES审批流设计】发起后由引擎进入首节点(解析处理人/建进度/发可办理卡片)-----
Integer tenantId = MesXslTenantUtils.resolveTenantId(flow.getTenantId());
//update-begin---author:GHT ---date:20260604 for【QH-MES审批台账】发起审批统一门禁与台账写入-----
try {
approvalGateService.assertCanLaunch(flow.getBizTable(), bizDataId, tenantId);
} catch (IllegalStateException e) {
return Result.error(e.getMessage());
}
MesXslApprovalInstance inst = buildInstance(flow, bizDataId, bizTitle, loginUser);
approvalInstanceService.save(inst);
approvalGateService.createRunningRecord(
approvalGateService.buildMesDraft(flow.getBizTable(), flow.getBizTableName(), bizDataId, bizTitle,
flow.getId(), flow.getFlowName(), inst.getId(), loginUser, tenantId));
approvalHandleService.enterFirstNode(inst, loginUser);
//update-end---author:GHT ---date:2026-05-29 for【QH-MES审批流设计】发起后由引擎进入首节点(解析处理人/建进度/发可办理卡片)-----
syncApprovalRecordAfterLaunch(inst.getId());
//update-end---author:GHT ---date:20260604 for【QH-MES审批台账】发起审批统一门禁与台账写入-----
return Result.OK("发起成功!");
}
@@ -218,23 +224,25 @@ public class MesXslApprovalLaunchController {
if (oConvertUtils.isEmpty(bizDataId)) {
continue;
}
//update-begin---author:GHT ---date:2026-05-29 for【QH-MES审批流完善】批量发起防重:跳过已有审批中实例的单据-----
long active = approvalInstanceService.count(new LambdaQueryWrapper<MesXslApprovalInstance>()
.eq(MesXslApprovalInstance::getBizTable, flow.getBizTable())
.eq(MesXslApprovalInstance::getBizDataId, bizDataId)
.eq(MesXslApprovalInstance::getStatus, "0"));
if (active > 0) {
//update-begin---author:GHT ---date:20260604 for【QH-MES审批台账】批量发起统一门禁与台账写入-----
Integer tenantId = MesXslTenantUtils.resolveTenantId(flow.getTenantId());
ApprovalGateVo gate = approvalGateService.checkCanLaunch(flow.getBizTable(), bizDataId, tenantId);
if (gate.getAllowed() == null || !gate.getAllowed()) {
skipCount++;
continue;
}
//update-end---author:GHT ---date:2026-05-29 for【QH-MES审批流完善】批量发起防重跳过已有审批中实例的单据-----
MesXslApprovalInstance inst = buildInstance(flow, bizDataId, bizTitle, loginUser);
approvalInstanceService.save(inst);
approvalGateService.createRunningRecord(
approvalGateService.buildMesDraft(flow.getBizTable(), flow.getBizTableName(), bizDataId, bizTitle,
flow.getId(), flow.getFlowName(), inst.getId(), loginUser, tenantId));
approvalHandleService.enterFirstNode(inst, loginUser);
syncApprovalRecordAfterLaunch(inst.getId());
//update-end---author:GHT ---date:20260604 for【QH-MES审批台账】批量发起统一门禁与台账写入-----
count++;
}
if (count == 0) {
return Result.error(skipCount > 0 ? "所选单据均已有审批中的流程,无需重复发起" : "没有有效的单据数据");
return Result.error(skipCount > 0 ? "所选单据均不允许发起审批(可能已有流转中或已通过流程)" : "没有有效的单据数据");
}
String msg = "已发起 " + count + " 条审批!";
if (skipCount > 0) {
@@ -265,4 +273,14 @@ public class MesXslApprovalLaunchController {
// 处理人解析、节点进度初始化、卡片发送统一由 IMesXslApprovalHandleService.enterFirstNode 完成
return inst;
}
//update-begin---author:GHT ---date:20260604 for【QH-MES审批台账】发起后同步台账终态(如无节点自动通过)-----
private void syncApprovalRecordAfterLaunch(String instanceId) {
MesXslApprovalInstance latest = approvalInstanceService.getById(instanceId);
if (latest == null || "0".equals(latest.getStatus())) {
return;
}
approvalGateService.finishByMesInstance(instanceId, latest.getStatus(), latest.getCurrentHandlersText());
}
//update-end---author:GHT ---date:20260604 for【QH-MES审批台账】发起后同步台账终态(如无节点自动通过)-----
}

View File

@@ -0,0 +1,69 @@
package org.jeecg.modules.xslmes.approval.controller;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.servlet.http.HttpServletRequest;
import lombok.extern.slf4j.Slf4j;
import org.apache.shiro.authz.annotation.RequiresPermissions;
import org.jeecg.common.api.vo.Result;
import org.jeecg.common.system.base.controller.JeecgController;
import org.jeecg.common.system.query.QueryGenerator;
import org.jeecg.modules.xslmes.approval.entity.MesXslApprovalRecord;
import org.jeecg.modules.xslmes.approval.service.IMesXslApprovalRecordService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.servlet.ModelAndView;
/**
* MES 审批台账(只读查询)
*
* @author GHT
* @date 2026-06-04 for【QH-MES审批台账】台账列表菜单
*/
@Tag(name = "MES审批台账")
@RestController
@RequestMapping("/xslmes/mesXslApprovalRecord")
@Slf4j
public class MesXslApprovalRecordController extends JeecgController<MesXslApprovalRecord, IMesXslApprovalRecordService> {
@Autowired
private IMesXslApprovalRecordService mesXslApprovalRecordService;
@Operation(summary = "MES审批台账-分页列表查询")
@RequiresPermissions("xslmes:mes_xsl_approval_record:list")
@GetMapping(value = "/list")
public Result<IPage<MesXslApprovalRecord>> queryPageList(
MesXslApprovalRecord model,
@RequestParam(name = "pageNo", defaultValue = "1") Integer pageNo,
@RequestParam(name = "pageSize", defaultValue = "10") Integer pageSize,
HttpServletRequest req) {
QueryWrapper<MesXslApprovalRecord> queryWrapper = QueryGenerator.initQueryWrapper(model, req.getParameterMap());
queryWrapper.orderByDesc("apply_time").orderByDesc("create_time");
Page<MesXslApprovalRecord> page = new Page<>(pageNo, pageSize);
IPage<MesXslApprovalRecord> pageList = mesXslApprovalRecordService.page(page, queryWrapper);
return Result.OK(pageList);
}
@Operation(summary = "MES审批台账-通过id查询")
@RequiresPermissions("xslmes:mes_xsl_approval_record:list")
@GetMapping(value = "/queryById")
public Result<MesXslApprovalRecord> queryById(@RequestParam(name = "id") String id) {
MesXslApprovalRecord entity = mesXslApprovalRecordService.getById(id);
if (entity == null) {
return Result.error("未找到对应数据");
}
return Result.OK(entity);
}
@RequiresPermissions("xslmes:mes_xsl_approval_record:exportXls")
@RequestMapping(value = "/exportXls")
public ModelAndView exportXls(HttpServletRequest request, MesXslApprovalRecord model) {
return super.exportXls(request, model, MesXslApprovalRecord.class, "MES审批台账");
}
}

View File

@@ -0,0 +1,111 @@
package org.jeecg.modules.xslmes.approval.entity;
import com.baomidou.mybatisplus.annotation.TableLogic;
import com.baomidou.mybatisplus.annotation.TableName;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.experimental.Accessors;
import org.jeecg.common.aspect.annotation.Dict;
import org.jeecg.common.system.base.entity.JeecgEntity;
import org.springframework.format.annotation.DateTimeFormat;
import java.io.Serializable;
import java.util.Date;
/**
* MES 审批台账(跨 MES/钉钉 统一门禁)
*
* @author GHT
* @date 2026-06-04 for【QH-MES审批台账】跨通道审批门禁
*/
@Data
@EqualsAndHashCode(callSuper = false)
@Accessors(chain = true)
@TableName("mes_xsl_approval_record")
@Schema(description = "MES审批台账")
public class MesXslApprovalRecord extends JeecgEntity implements Serializable {
private static final long serialVersionUID = 1L;
@Schema(description = "业务单据表名")
private String bizTable;
@Schema(description = "业务单据中文名")
private String bizTableName;
@Schema(description = "业务编码(菜单permission.id)")
private String bizCode;
@Schema(description = "业务单据记录ID")
private String bizDataId;
@Schema(description = "业务单据展示标题")
private String bizTitle;
@Schema(description = "审批通道 MES/DINGTALK")
@Dict(dicCode = "mes_xsl_approval_channel")
private String channel;
@Schema(description = "外部实例ID(MES实例ID或钉钉instanceId)")
private String externalInstanceId;
@Schema(description = "MES审批流ID")
private String flowId;
@Schema(description = "MES审批流名称")
private String flowName;
@Schema(description = "钉钉审批模板ID")
private String templateId;
@Schema(description = "钉钉审批模板名称")
private String templateName;
@Schema(description = "同一业务单据第几次发起")
private Integer launchNo;
@Schema(description = "状态 0流转中 1通过 2拒绝 3撤销 4发起失败")
@Dict(dicCode = "mes_xsl_approval_record_status")
private String status;
@Schema(description = "发起人username")
private String applyUser;
@Schema(description = "发起人姓名")
private String applyUserName;
@Schema(description = "发起时间")
@DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private Date applyTime;
@Schema(description = "办结时间")
@DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private Date finishTime;
//update-begin---author:GHT ---date:20260604 for【钉钉Stream回调】与 MesXslApprovalInstance 对齐,复用 isBizAtOriginStatus 逻辑-----
@Schema(description = "业务单据状态字段名(发起时快照,驳回回写依据)")
private String statusField;
@Schema(description = "发起审批时业务状态原值(驳回回写依据)")
private String originStatus;
//update-end---author:GHT ---date:20260604 for【钉钉Stream回调】与 MesXslApprovalInstance 对齐,复用 isBizAtOriginStatus 逻辑-----
//update-begin---author:GHT ---date:2026-06-04 for【20260604】钉钉回调幂等去重DB乐观锁字段记录已处理的节点回调数-----
@Schema(description = "钉钉回调已处理节点数(幂等去重,勿用于业务逻辑)")
private Integer processedOpCount;
//update-end---author:GHT ---date:2026-06-04 for【20260604】钉钉回调幂等去重DB乐观锁字段记录已处理的节点回调数-----
@Schema(description = "备注")
private String remark;
@Schema(description = "逻辑删除 0正常 1已删除")
@TableLogic
private Integer delFlag;
@Schema(description = "租户ID")
private Integer tenantId;
@Schema(description = "所属部门编码")
private String sysOrgCode;
}

View File

@@ -0,0 +1,13 @@
package org.jeecg.modules.xslmes.approval.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import org.jeecg.modules.xslmes.approval.entity.MesXslApprovalRecord;
/**
* MES 审批台账 Mapper
*
* @author GHT
* @date 2026-06-04 for【QH-MES审批台账】跨通道审批门禁
*/
public interface MesXslApprovalRecordMapper extends BaseMapper<MesXslApprovalRecord> {
}

View File

@@ -0,0 +1,80 @@
package org.jeecg.modules.xslmes.approval.service;
import org.jeecg.common.system.vo.LoginUser;
import org.jeecg.modules.xslmes.approval.entity.MesXslApprovalRecord;
import org.jeecg.modules.xslmes.approval.vo.ApprovalGateVo;
import java.util.List;
import java.util.Map;
/**
* 审批门禁服务:统一判断能否发起审批,并维护审批台账
*
* @author GHT
* @date 2026-06-04 for【QH-MES审批台账】跨通道审批门禁
*/
public interface IMesXslApprovalGateService {
/**
* 检查是否允许对指定业务单据发起审批(跨 MES/钉钉通道)
*/
ApprovalGateVo checkCanLaunch(String bizTable, String bizDataId, Integer tenantId);
/**
* 批量检查是否允许发起审批
*/
List<ApprovalGateVo> checkCanLaunchBatch(String bizTable, List<String> bizDataIds, Integer tenantId);
/**
* 校验允许发起,不允许则抛出 IllegalStateException供 Controller 转 Result.error
*/
void assertCanLaunch(String bizTable, String bizDataId, Integer tenantId);
/**
* 发起审批时写入台账(状态=流转中)
*/
MesXslApprovalRecord createRunningRecord(MesXslApprovalRecord draft);
/**
* MES 审批实例办结时同步台账
*/
void finishByMesInstance(String mesInstanceId, String status, String remark);
/**
* 按外部实例 ID 办结台账(预留钉钉回调等场景)。
* 返回 true 表示本次调用实际更新了台账(状态从流转中变为终态);
* 返回 false 表示台账已是终态,本次为重复调用。
*/
//update-begin---author:GHT ---date:2026-06-04 for【20260604】钉钉回调幂等去重返回是否实际更新供调用方判断是否跳过后续业务回调-----
boolean finishByExternalInstance(String channel, String externalInstanceId, String status, String remark);
//update-end---author:GHT ---date:2026-06-04 for【20260604】钉钉回调幂等去重返回是否实际更新供调用方判断是否跳过后续业务回调-----
//update-begin---author:GHT ---date:2026-06-04 for【20260604】钉钉回调幂等去重DB乐观锁标记节点已处理-----
/**
* 用乐观锁尝试将台账的 processed_op_count 推进到 nodeIndex+1。
* 返回 true 表示成功(本次是第一个处理该节点的线程);
* 返回 false 表示已被其他线程/事件处理过,调用方应跳过。
*/
boolean tryMarkNodeProcessed(String recordId, int nodeIndex);
//update-end---author:GHT ---date:2026-06-04 for【20260604】钉钉回调幂等去重DB乐观锁标记节点已处理-----
/**
* 查询业务单据审批历史(按发起时间倒序)
*/
List<MesXslApprovalRecord> listHistory(String bizTable, String bizDataId, Integer tenantId);
/**
* 构建 MES 通道台账草稿
*/
MesXslApprovalRecord buildMesDraft(String bizTable, String bizTableName, String bizDataId, String bizTitle,
String flowId, String flowName, String mesInstanceId, LoginUser loginUser,
Integer tenantId);
/**
* 构建钉钉通道台账草稿
*/
MesXslApprovalRecord buildDingDraft(String bizTable, String bizTableName, String bizCode, String bizDataId,
String bizTitle, String flowId, String flowName, String templateId,
String templateName, String dingInstanceId, LoginUser loginUser,
Integer tenantId);
}

View File

@@ -0,0 +1,13 @@
package org.jeecg.modules.xslmes.approval.service;
import com.baomidou.mybatisplus.extension.service.IService;
import org.jeecg.modules.xslmes.approval.entity.MesXslApprovalRecord;
/**
* MES 审批台账 Service
*
* @author GHT
* @date 2026-06-04 for【QH-MES审批台账】跨通道审批门禁
*/
public interface IMesXslApprovalRecordService extends IService<MesXslApprovalRecord> {
}

View File

@@ -0,0 +1,302 @@
package org.jeecg.modules.xslmes.approval.service.impl;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
import lombok.extern.slf4j.Slf4j;
import org.jeecg.common.system.vo.LoginUser;
import org.jeecg.common.util.oConvertUtils;
import org.jeecg.modules.xslmes.approval.constant.ApprovalRecordConstants;
import org.jeecg.modules.xslmes.approval.entity.MesXslApprovalRecord;
import org.jeecg.modules.xslmes.approval.service.IMesXslApprovalGateService;
import org.jeecg.modules.xslmes.approval.service.IMesXslApprovalRecordService;
import org.jeecg.modules.xslmes.approval.vo.ApprovalGateVo;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.ArrayList;
import java.util.Date;
import java.util.List;
/**
* 审批门禁服务实现
*
* @author GHT
* @date 2026-06-04 for【QH-MES审批台账】跨通道审批门禁
*/
@Slf4j
@Service
public class MesXslApprovalGateServiceImpl implements IMesXslApprovalGateService {
@Autowired
private IMesXslApprovalRecordService recordService;
@Autowired
private JdbcTemplate jdbcTemplate;
@Override
public ApprovalGateVo checkCanLaunch(String bizTable, String bizDataId, Integer tenantId) {
ApprovalGateVo vo = new ApprovalGateVo()
.setBizTable(bizTable)
.setBizDataId(bizDataId);
if (oConvertUtils.isEmpty(bizTable) || oConvertUtils.isEmpty(bizDataId)) {
return vo.setAllowed(false).setReason("业务单据信息不完整,无法判断审批状态");
}
MesXslApprovalRecord latest = findLatest(bizTable, bizDataId, tenantId);
if (latest == null) {
return vo.setAllowed(true).setReason("尚未发起过审批,允许发起");
}
fillLatestInfo(vo, latest);
if (ApprovalRecordConstants.STATUS_RUNNING.equals(latest.getStatus())) {
String channelText = channelText(latest.getChannel());
return vo.setAllowed(false).setReason("该单据已有" + channelText + "流程流转中,请勿重复发起");
}
if (ApprovalRecordConstants.STATUS_APPROVED.equals(latest.getStatus())) {
return vo.setAllowed(false).setReason("该单据已审批通过,无需重复发起");
}
if (ApprovalRecordConstants.STATUS_REJECTED.equals(latest.getStatus())) {
return vo.setAllowed(true).setReason("上次审批已被拒绝,允许重新发起");
}
if (ApprovalRecordConstants.STATUS_CANCELLED.equals(latest.getStatus())) {
return vo.setAllowed(true).setReason("上次审批已撤销,允许重新发起");
}
if (ApprovalRecordConstants.STATUS_LAUNCH_FAILED.equals(latest.getStatus())) {
return vo.setAllowed(true).setReason("上次发起失败,允许重新发起");
}
return vo.setAllowed(true).setReason("允许发起审批");
}
@Override
public List<ApprovalGateVo> checkCanLaunchBatch(String bizTable, List<String> bizDataIds, Integer tenantId) {
List<ApprovalGateVo> list = new ArrayList<>();
if (bizDataIds == null || bizDataIds.isEmpty()) {
return list;
}
for (String bizDataId : bizDataIds) {
if (oConvertUtils.isEmpty(bizDataId)) {
continue;
}
list.add(checkCanLaunch(bizTable, bizDataId, tenantId));
}
return list;
}
@Override
public void assertCanLaunch(String bizTable, String bizDataId, Integer tenantId) {
ApprovalGateVo gate = checkCanLaunch(bizTable, bizDataId, tenantId);
if (gate.getAllowed() == null || !gate.getAllowed()) {
throw new IllegalStateException(oConvertUtils.getString(gate.getReason(), "当前不允许发起审批"));
}
}
@Override
@Transactional(rollbackFor = Exception.class)
public MesXslApprovalRecord createRunningRecord(MesXslApprovalRecord draft) {
if (draft == null) {
throw new IllegalArgumentException("台账数据不能为空");
}
assertCanLaunch(draft.getBizTable(), draft.getBizDataId(), draft.getTenantId());
Date now = new Date();
if (draft.getApplyTime() == null) {
draft.setApplyTime(now);
}
draft.setStatus(ApprovalRecordConstants.STATUS_RUNNING);
draft.setLaunchNo(nextLaunchNo(draft.getBizTable(), draft.getBizDataId(), draft.getTenantId()));
recordService.save(draft);
return draft;
}
@Override
@Transactional(rollbackFor = Exception.class)
public void finishByMesInstance(String mesInstanceId, String status, String remark) {
finishByExternalInstance(ApprovalRecordConstants.CHANNEL_MES, mesInstanceId, status, remark);
}
//update-begin---author:GHT ---date:2026-06-04 for【20260604】钉钉回调幂等去重返回实际影响行数0表示台账已是终态-----
@Override
@Transactional(rollbackFor = Exception.class)
public boolean finishByExternalInstance(String channel, String externalInstanceId, String status, String remark) {
if (oConvertUtils.isEmpty(externalInstanceId) || oConvertUtils.isEmpty(status)) {
return false;
}
if (ApprovalRecordConstants.STATUS_RUNNING.equals(status)) {
return false;
}
LambdaUpdateWrapper<MesXslApprovalRecord> uw = new LambdaUpdateWrapper<MesXslApprovalRecord>()
.eq(MesXslApprovalRecord::getExternalInstanceId, externalInstanceId)
.eq(MesXslApprovalRecord::getStatus, ApprovalRecordConstants.STATUS_RUNNING);
if (oConvertUtils.isNotEmpty(channel)) {
uw.eq(MesXslApprovalRecord::getChannel, channel);
}
uw.set(MesXslApprovalRecord::getStatus, status)
.set(MesXslApprovalRecord::getFinishTime, new Date());
if (oConvertUtils.isNotEmpty(remark)) {
uw.set(MesXslApprovalRecord::getRemark, remark);
}
// getBaseMapper().update() 返回实际影响行数0 = 台账已是终态(重复事件)
int affected = recordService.getBaseMapper().update(null, uw);
return affected > 0;
}
@Override
public boolean tryMarkNodeProcessed(String recordId, int nodeIndex) {
if (oConvertUtils.isEmpty(recordId)) return false;
// 乐观锁processed_op_count < nodeIndex+1 时才推进,保证同一节点只被处理一次
// 两个并发线程同时到达时MySQL 行锁保证只有一个 UPDATE 成功
int affected = jdbcTemplate.update(
"UPDATE mes_xsl_approval_record SET processed_op_count = ? " +
"WHERE id = ? AND processed_op_count < ? AND del_flag = 0",
nodeIndex + 1, recordId, nodeIndex + 1);
return affected > 0;
}
//update-end---author:GHT ---date:2026-06-04 for【20260604】钉钉回调幂等去重返回实际影响行数0表示台账已是终态-----
@Override
public List<MesXslApprovalRecord> listHistory(String bizTable, String bizDataId, Integer tenantId) {
if (oConvertUtils.isEmpty(bizTable) || oConvertUtils.isEmpty(bizDataId)) {
return List.of();
}
LambdaQueryWrapper<MesXslApprovalRecord> qw = baseBizQuery(bizTable, bizDataId, tenantId);
qw.orderByDesc(MesXslApprovalRecord::getApplyTime).orderByDesc(MesXslApprovalRecord::getCreateTime);
return recordService.list(qw);
}
@Override
public MesXslApprovalRecord buildMesDraft(String bizTable, String bizTableName, String bizDataId, String bizTitle,
String flowId, String flowName, String mesInstanceId, LoginUser loginUser,
Integer tenantId) {
MesXslApprovalRecord record = new MesXslApprovalRecord();
record.setBizTable(bizTable);
record.setBizTableName(bizTableName);
record.setBizDataId(bizDataId);
record.setBizTitle(oConvertUtils.isNotEmpty(bizTitle) ? bizTitle : bizDataId);
record.setChannel(ApprovalRecordConstants.CHANNEL_MES);
record.setExternalInstanceId(mesInstanceId);
record.setFlowId(flowId);
record.setFlowName(flowName);
record.setTenantId(tenantId);
fillApplyUser(record, loginUser);
return record;
}
@Override
public MesXslApprovalRecord buildDingDraft(String bizTable, String bizTableName, String bizCode, String bizDataId,
String bizTitle, String flowId, String flowName, String templateId,
String templateName, String dingInstanceId, LoginUser loginUser,
Integer tenantId) {
MesXslApprovalRecord record = new MesXslApprovalRecord();
record.setBizTable(bizTable);
record.setBizTableName(bizTableName);
record.setBizCode(bizCode);
record.setBizDataId(bizDataId);
record.setBizTitle(oConvertUtils.isNotEmpty(bizTitle) ? bizTitle : bizDataId);
record.setChannel(ApprovalRecordConstants.CHANNEL_DINGTALK);
record.setExternalInstanceId(dingInstanceId);
record.setFlowId(flowId);
record.setFlowName(flowName);
record.setTemplateId(templateId);
record.setTemplateName(templateName);
record.setTenantId(tenantId);
fillApplyUser(record, loginUser);
//update-begin---author:GHT ---date:20260604 for【钉钉Stream回调】快照业务状态原值复用 isBizAtOriginStatus 逻辑-----
snapshotOriginStatus(record, bizTable, bizDataId);
//update-end---author:GHT ---date:20260604 for【钉钉Stream回调】快照业务状态原值复用 isBizAtOriginStatus 逻辑-----
return record;
}
//update-begin---author:GHT ---date:20260604 for【钉钉Stream回调】发起时快照业务状态驳回时供共用判断-----
/**
* 探测业务表是否存在 status 字段,若存在则读取当前值快照到台账。
* 与 MesXslApprovalHandleServiceImpl.snapshotBizStatus() 逻辑对齐,
* 使驳回时的 isBizAtOriginStatus 判断可跨 MES/钉钉两通道复用。
*/
private void snapshotOriginStatus(MesXslApprovalRecord record, String bizTable, String bizDataId) {
if (oConvertUtils.isEmpty(bizTable) || oConvertUtils.isEmpty(bizDataId)) return;
if (!bizTable.matches("^[A-Za-z0-9_]+$")) return;
try {
Integer cnt = jdbcTemplate.queryForObject(
"SELECT COUNT(1) FROM information_schema.columns " +
"WHERE table_schema=(SELECT DATABASE()) AND table_name=? AND column_name='status'",
Integer.class, bizTable);
if (cnt == null || cnt == 0) return;
java.util.List<String> vals = jdbcTemplate.queryForList(
"SELECT status FROM " + bizTable + " WHERE id=? LIMIT 1", String.class, bizDataId);
if (!vals.isEmpty()) {
record.setStatusField("status");
record.setOriginStatus(vals.get(0));
}
} catch (Exception e) {
log.warn("[ApprovalGate] 快照业务状态失败 table={} id={}: {}", bizTable, bizDataId, e.getMessage());
}
}
//update-end---author:GHT ---date:20260604 for【钉钉Stream回调】发起时快照业务状态驳回时供共用判断-----
private MesXslApprovalRecord findLatest(String bizTable, String bizDataId, Integer tenantId) {
LambdaQueryWrapper<MesXslApprovalRecord> qw = baseBizQuery(bizTable, bizDataId, tenantId);
qw.orderByDesc(MesXslApprovalRecord::getApplyTime).orderByDesc(MesXslApprovalRecord::getCreateTime).last("LIMIT 1");
return recordService.getOne(qw, false);
}
private LambdaQueryWrapper<MesXslApprovalRecord> baseBizQuery(String bizTable, String bizDataId, Integer tenantId) {
LambdaQueryWrapper<MesXslApprovalRecord> qw = new LambdaQueryWrapper<>();
qw.eq(MesXslApprovalRecord::getBizTable, bizTable)
.eq(MesXslApprovalRecord::getBizDataId, bizDataId);
if (tenantId != null) {
qw.eq(MesXslApprovalRecord::getTenantId, tenantId);
}
return qw;
}
private int nextLaunchNo(String bizTable, String bizDataId, Integer tenantId) {
LambdaQueryWrapper<MesXslApprovalRecord> qw = baseBizQuery(bizTable, bizDataId, tenantId);
long count = recordService.count(qw);
return (int) count + 1;
}
private void fillLatestInfo(ApprovalGateVo vo, MesXslApprovalRecord latest) {
vo.setLatestRecordId(latest.getId())
.setLatestStatus(latest.getStatus())
.setLatestChannel(latest.getChannel())
.setLatestChannelText(channelText(latest.getChannel()))
.setLatestStatusText(statusText(latest.getStatus()));
}
private void fillApplyUser(MesXslApprovalRecord record, LoginUser loginUser) {
if (loginUser == null) {
return;
}
record.setApplyUser(loginUser.getUsername());
record.setApplyUserName(loginUser.getRealname());
record.setSysOrgCode(loginUser.getOrgCode());
}
private String channelText(String channel) {
if (ApprovalRecordConstants.CHANNEL_DINGTALK.equals(channel)) {
return "钉钉审批";
}
if (ApprovalRecordConstants.CHANNEL_MES.equals(channel)) {
return "MES审批";
}
return oConvertUtils.getString(channel, "审批");
}
private String statusText(String status) {
if (ApprovalRecordConstants.STATUS_RUNNING.equals(status)) {
return "流转中";
}
if (ApprovalRecordConstants.STATUS_APPROVED.equals(status)) {
return "审批通过";
}
if (ApprovalRecordConstants.STATUS_REJECTED.equals(status)) {
return "审批拒绝";
}
if (ApprovalRecordConstants.STATUS_CANCELLED.equals(status)) {
return "已撤销";
}
if (ApprovalRecordConstants.STATUS_LAUNCH_FAILED.equals(status)) {
return "发起失败";
}
return oConvertUtils.getString(status, "");
}
}

View File

@@ -18,6 +18,7 @@ import org.jeecg.modules.xslmes.approval.callback.ApprovalCallbackDispatcher;
import org.jeecg.modules.xslmes.approval.entity.MesXslApprovalFlow;
import org.jeecg.modules.xslmes.approval.entity.MesXslApprovalInstance;
import org.jeecg.modules.xslmes.approval.service.IMesXslApprovalFlowService;
import org.jeecg.modules.xslmes.approval.service.IMesXslApprovalGateService;
import org.jeecg.modules.xslmes.approval.service.IMesXslApprovalHandleService;
import org.jeecg.modules.xslmes.approval.service.IMesXslApprovalInstanceService;
import org.springframework.beans.factory.annotation.Autowired;
@@ -68,6 +69,11 @@ public class MesXslApprovalHandleServiceImpl implements IMesXslApprovalHandleSer
@Autowired
private IMesXslApprovalInstanceService instanceService;
//update-begin---author:GHT ---date:20260604 for【QH-MES审批台账】MES审批办结同步台账-----
@Autowired
private IMesXslApprovalGateService approvalGateService;
//update-end---author:GHT ---date:20260604 for【QH-MES审批台账】MES审批办结同步台账-----
@Autowired
private JdbcTemplate jdbcTemplate;
@@ -121,6 +127,7 @@ public class MesXslApprovalHandleServiceImpl implements IMesXslApprovalHandleSer
inst.setCurrentNodeName("无审批节点");
inst.setCurrentHandlersText("无审批节点,自动通过");
instanceService.updateById(inst);
syncApprovalRecord(inst, "无审批节点,自动通过");
// 无审批节点直接最终通过 -> 回调业务
callbackDispatcher.fireApproved(buildContext(inst, inst.getCurrentNodeId(), "无审批节点", applyUser, "无审批节点,自动通过"));
notifyApplicant(inst, applyUser == null ? null : applyUser.getUsername(),
@@ -209,6 +216,7 @@ public class MesXslApprovalHandleServiceImpl implements IMesXslApprovalHandleSer
inst.setCurrentHandlers(null);
inst.setCurrentHandlersText("审批通过");
instanceService.updateById(inst);
syncApprovalRecord(inst, comment);
// 无后续流程,直接最终通过
callbackDispatcher.fireApproved(buildContext(inst, progress.getString("nodeId"), progress.getString("nodeName"), user, comment));
return Result.OK("审批通过");
@@ -258,6 +266,7 @@ public class MesXslApprovalHandleServiceImpl implements IMesXslApprovalHandleSer
inst.setCurrentHandlersText("已驳回");
inst.setNodeProgress(progress.toJSONString());
instanceService.updateById(inst);
syncApprovalRecord(inst, reason);
// 驳回 -> 回调业务(可回退业务状态)
callbackDispatcher.fireRejected(buildContext(inst, progress.getString("nodeId"), progress.getString("nodeName"), user, reason));
@@ -324,6 +333,7 @@ public class MesXslApprovalHandleServiceImpl implements IMesXslApprovalHandleSer
inst.setCurrentHandlers(null);
inst.setCurrentHandlersText("已撤销");
instanceService.updateById(inst);
syncApprovalRecord(inst, comment);
//update-begin---author:GHT ---date:2026-05-29 for【QH-MES审批流完善】撤销改为恢复发起前快照,不再走业务「拒绝/驳回」接口-----
// 撤销时单据仍处于发起前最初状态,只需把状态字段恢复到发起时快照(幂等)
@@ -423,6 +433,7 @@ public class MesXslApprovalHandleServiceImpl implements IMesXslApprovalHandleSer
inst.setCurrentHandlers(null);
inst.setCurrentHandlersText("审批人为空,流程终止");
instanceService.updateById(inst);
syncApprovalRecord(inst, "审批人为空,流程终止");
// 流程终止(等同驳回) -> 回调业务(可回退业务状态)
callbackDispatcher.fireRejected(buildContext(inst, nodeId, nodeName, null, "审批人为空,流程终止"));
//update-begin---author:GHT ---date:2026-05-29 for【QH-MES审批流设计】流程终止驳回统一回退(按表注解自动执行)-----
@@ -516,6 +527,7 @@ public class MesXslApprovalHandleServiceImpl implements IMesXslApprovalHandleSer
inst.setCurrentHandlers(null);
inst.setCurrentHandlersText("审批通过");
instanceService.updateById(inst);
syncApprovalRecord(inst, null);
// 流程最终通过 -> 回调业务(终态)
callbackDispatcher.fireApproved(buildContext(inst, currentNodeId, inst.getCurrentNodeName(), actingUser, null));
actionHttpExecutor.run(findNodeById(root, currentNodeId), "onApprove", inst);
@@ -1571,4 +1583,13 @@ public class MesXslApprovalHandleServiceImpl implements IMesXslApprovalHandleSer
}
}
//update-end---author:GHT ---date:2026-05-29 for【QH-MES审批流完善】全链路通知补全-----
//update-begin---author:GHT ---date:20260604 for【QH-MES审批台账】MES审批办结同步台账-----
private void syncApprovalRecord(MesXslApprovalInstance inst, String remark) {
if (inst == null || oConvertUtils.isEmpty(inst.getId()) || "0".equals(inst.getStatus())) {
return;
}
approvalGateService.finishByMesInstance(inst.getId(), inst.getStatus(), remark);
}
//update-end---author:GHT ---date:20260604 for【QH-MES审批台账】MES审批办结同步台账-----
}

View File

@@ -0,0 +1,18 @@
package org.jeecg.modules.xslmes.approval.service.impl;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import org.jeecg.modules.xslmes.approval.entity.MesXslApprovalRecord;
import org.jeecg.modules.xslmes.approval.mapper.MesXslApprovalRecordMapper;
import org.jeecg.modules.xslmes.approval.service.IMesXslApprovalRecordService;
import org.springframework.stereotype.Service;
/**
* MES 审批台账 ServiceImpl
*
* @author GHT
* @date 2026-06-04 for【QH-MES审批台账】跨通道审批门禁
*/
@Service
public class MesXslApprovalRecordServiceImpl extends ServiceImpl<MesXslApprovalRecordMapper, MesXslApprovalRecord>
implements IMesXslApprovalRecordService {
}

View File

@@ -0,0 +1,48 @@
package org.jeecg.modules.xslmes.approval.vo;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import lombok.experimental.Accessors;
import java.io.Serializable;
/**
* 审批门禁检查结果
*
* @author GHT
* @date 2026-06-04 for【QH-MES审批台账】跨通道审批门禁
*/
@Data
@Accessors(chain = true)
@Schema(description = "审批门禁检查结果")
public class ApprovalGateVo implements Serializable {
private static final long serialVersionUID = 1L;
@Schema(description = "是否允许发起审批")
private Boolean allowed;
@Schema(description = "不允许时的原因说明")
private String reason;
@Schema(description = "业务单据表名")
private String bizTable;
@Schema(description = "业务单据记录ID")
private String bizDataId;
@Schema(description = "最新台账记录ID")
private String latestRecordId;
@Schema(description = "最新台账状态")
private String latestStatus;
@Schema(description = "最新台账通道 MES/DINGTALK")
private String latestChannel;
@Schema(description = "最新台账通道展示名")
private String latestChannelText;
@Schema(description = "最新台账状态展示名")
private String latestStatusText;
}

View File

@@ -1,26 +1,14 @@
package org.jeecg.modules.xslmes.controller;
import com.alibaba.fastjson2.JSONArray;
import com.alibaba.fastjson2.JSONObject;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.jeecg.dingtalk.api.core.response.Response;
import com.jeecg.dingtalk.api.user.JdtUserAPI;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.text.SimpleDateFormat;
import java.time.Duration;
import java.util.Arrays;
import java.util.Date;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.Map;
import lombok.extern.slf4j.Slf4j;
import org.apache.shiro.SecurityUtils;
@@ -34,7 +22,6 @@ import org.jeecg.common.system.vo.LoginUser;
import org.jeecg.common.util.oConvertUtils;
import org.jeecg.modules.system.entity.SysDepart;
import org.jeecg.modules.system.service.ISysDepartService;
import org.jeecg.modules.system.service.impl.ThirdAppDingtalkServiceImpl;
import org.jeecg.modules.xslmes.approval.action.ApprovalBizAction;
import org.jeecg.modules.xslmes.entity.MesXslMixerPsCompile;
import org.jeecg.modules.xslmes.service.IMesXslMixerPsCompileService;
@@ -58,8 +45,6 @@ public class MesXslMixerPsCompileController extends JeecgController<MesXslMixerP
private ISysDepartService sysDepartService;
//update-begin---author:GHT ---date:2026-06-03 for【钉钉PROC-GENERIC可行性验证】注入钉钉服务-----
@Autowired
private ThirdAppDingtalkServiceImpl dingtalkService;
//update-end---author:GHT ---date:2026-06-03 for【钉钉PROC-GENERIC可行性验证】注入钉钉服务-----
@Operation(summary = "MES密炼PS编制-分页列表查询")
@@ -301,122 +286,6 @@ public class MesXslMixerPsCompileController extends JeecgController<MesXslMixerP
}
//update-end---author:jiangxh ---date:20260520 for【密炼PS编制】保存前校验与冗余回填-----------
//update-begin---author:GHT ---date:2026-06-03 for【钉钉PROC-GENERIC可行性验证】测试接口-----
@Operation(summary = "钉钉审批测试用当前登录人手机号获取钉钉userId并尝试PROC-GENERIC发起审批")
@PostMapping(value = "/ddApprovalTest")
public Result<Map<String, Object>> ddApprovalTest() {
Map<String, Object> result = new LinkedHashMap<>();
// ① 取当前登录用户
LoginUser loginUser = (LoginUser) SecurityUtils.getSubject().getPrincipal();
String username = loginUser.getUsername();
String realname = oConvertUtils.isNotEmpty(loginUser.getRealname()) ? loginUser.getRealname() : username;
String phone = loginUser.getPhone();
result.put("step1_user", realname + "(" + username + ")");
result.put("step1_phone", oConvertUtils.isNotEmpty(phone) ? phone : "【未配置手机号】");
if (oConvertUtils.isEmpty(phone)) {
result.put("结论", "当前账号未配置手机号无法查询钉钉userId");
return Result.OK(result);
}
// ② 获取 AccessToken
String accessToken;
try {
accessToken = dingtalkService.getAccessToken();
} catch (Exception e) {
result.put("step2_accessToken", "获取失败: " + e.getMessage());
return Result.OK(result);
}
if (oConvertUtils.isEmpty(accessToken)) {
result.put("step2_accessToken", "获取失败,请在[系统配置-第三方应用]中检查钉钉配置");
return Result.OK(result);
}
result.put("step2_accessToken", accessToken.substring(0, Math.min(10, accessToken.length())) + "...[已截断]");
// ③ 手机号查钉钉 userId
Response<String> userIdResp = JdtUserAPI.getUseridByMobile(phone, accessToken);
result.put("step3_getUseridByMobile_errcode", userIdResp.getErrcode());
result.put("step3_getUseridByMobile_errmsg", userIdResp.getErrmsg());
result.put("step3_dingtalkUserId", userIdResp.isSuccess() ? userIdResp.getResult() : "查询失败");
if (!userIdResp.isSuccess() || oConvertUtils.isEmpty(userIdResp.getResult())) {
result.put("结论", "手机号未在钉钉通讯录中找到对应用户,请确认该手机号已在企业钉钉中注册");
return Result.OK(result);
}
String dtUserId = userIdResp.getResult();
// ④ 用真实模板 processCode 发起审批实例
try {
String processCode = "PROC-71957EB4-6F64-4AD7-AA1C-3CD7E797687B"; // MES测试审批流
String url = "https://api.dingtalk.com/v1.0/workflow/processInstances";
String now = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss").format(new Date());
// 表单字段(对应钉钉模板中的控件标题)
JSONArray formValues = new JSONArray();
for (String[] kv : new String[][]{
{"PS编码", "TEST-" + System.currentTimeMillis() % 10000},
{"类型", "MES测试"},
{"发行日期", new SimpleDateFormat("yyyy-MM-dd").format(new Date())},
{"发送部门", "技术部"},
{"标题", "MES钉钉审批测试 " + now},
}) {
JSONObject f = new JSONObject();
f.put("name", kv[0]);
f.put("value", kv[1]);
formValues.add(f);
}
// 审批人用当前登录人自己做测试审批人and会签只有1人
JSONArray approvers = new JSONArray();
JSONObject approverNode = new JSONObject();
approverNode.put("actionType", "AND");
JSONArray approverIds = new JSONArray();
approverIds.add(dtUserId);
approverNode.put("userIds", approverIds);
approvers.add(approverNode);
JSONObject reqBody = new JSONObject();
reqBody.put("processCode", processCode);
reqBody.put("originatorUserId", dtUserId);
reqBody.put("deptId", -1);
reqBody.put("approvers", approvers);
reqBody.put("formComponentValues", formValues);
result.put("step4_请求体", reqBody);
HttpClient httpClient = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(10))
.build();
HttpRequest httpReq = HttpRequest.newBuilder()
.uri(URI.create(url))
.header("x-acs-dingtalk-access-token", accessToken)
.header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString(reqBody.toJSONString()))
.timeout(Duration.ofSeconds(10))
.build();
String respBody = httpClient.send(httpReq, HttpResponse.BodyHandlers.ofString()).body();
JSONObject ddResp = JSONObject.parseObject(respBody);
result.put("step4_钉钉响应", ddResp);
if (ddResp.containsKey("instanceId") || ddResp.containsKey("processInstanceId")) {
String iid = ddResp.containsKey("instanceId")
? ddResp.getString("instanceId")
: ddResp.getString("processInstanceId");
result.put("结论", "✅ 审批实例创建成功instanceId=" + iid + ",请到钉钉「待我审批」查看");
} else {
result.put("结论", "❌ 创建失败code=" + ddResp.getString("code") + " msg=" + ddResp.getString("message"));
}
} catch (Exception e) {
log.error("钉钉审批实例创建测试异常", e);
result.put("step4_钉钉响应", "HTTP请求异常: " + e.getMessage());
result.put("结论", "❌ 请求钉钉接口失败");
}
return Result.OK(result);
}
//update-end---author:GHT ---date:2026-06-03 for【钉钉PROC-GENERIC可行性验证】测试接口-----
//update-begin---author:jiangxh ---date:20260520 for【密炼PS编制】仅编制状态允许删除-----------
private String assertCompileStatusForDelete(MesXslMixerPsCompile entity) {
if (entity == null) {

View File

@@ -5,6 +5,7 @@ import com.alibaba.fastjson2.JSONArray;
import com.alibaba.fastjson2.JSONObject;
import org.jeecg.modules.xslmes.approval.entity.MesXslApprovalFlow;
import org.jeecg.modules.xslmes.approval.service.IMesXslApprovalFlowService;
import org.jeecg.modules.xslmes.approval.service.IMesXslApprovalGateService;
import org.jeecg.modules.xslmes.dingtalk.dto.DingFormComponent;
import org.jeecg.modules.xslmes.dingtalk.dto.DingFormComponentProps;
import org.jeecg.modules.xslmes.dingtalk.dto.DingFormCreateRequest;
@@ -75,6 +76,9 @@ public class MesXslDingProcessTplController extends JeecgController<MesXslDingPr
@Autowired
private JdbcTemplate jdbcTemplate;
@Autowired
private IMesXslApprovalGateService approvalGateService;
//update-end---author:GHT ---date:2026-06-04 for【MESToDing审批配置】绑定MES审批流审批人来源-----------
@Operation(summary = "钉钉审批模板配置-分页列表查询")
@@ -98,10 +102,40 @@ public class MesXslDingProcessTplController extends JeecgController<MesXslDingPr
@RequiresPermissions("xslmes:mes_xsl_ding_process_tpl:add")
@PostMapping(value = "/add")
public Result<String> add(@RequestBody MesXslDingProcessTpl mesXslDingProcessTpl) {
if (oConvertUtils.isEmpty(mesXslDingProcessTpl.getProcessCode())) {
mesXslDingProcessTpl.setProcessCode("");
}
mesXslDingProcessTplService.save(mesXslDingProcessTpl);
return Result.OK("添加成功!");
}
//update-begin---author:GHT ---date:2026-06-04 for【MESToDing审批配置】新增审批模板本地草稿processCode 由后续创建钉钉模板回填)-----
@AutoLog(value = "钉钉审批模板配置-新增审批模板草稿")
@Operation(summary = "钉钉审批模板配置-新增审批模板(返回含 id 的记录,供打开表单设计器)")
@RequiresPermissions("xslmes:mes_xsl_ding_process_tpl:add")
@PostMapping(value = "/addNewTemplate")
public Result<MesXslDingProcessTpl> addNewTemplate(@RequestBody MesXslDingProcessTpl body) {
if (body == null || oConvertUtils.isEmpty(body.getTplName())) {
return Result.error("模板名称不能为空");
}
if (oConvertUtils.isEmpty(body.getStatus())) {
body.setStatus("1");
}
if (body.getSortNo() == null) {
body.setSortNo(0);
}
if (oConvertUtils.isEmpty(body.getFormFields())) {
body.setFormFields("[]");
}
// process_code 列 NOT NULL草稿阶段用空串占位创建钉钉模板后回填真实 processCode
if (oConvertUtils.isEmpty(body.getProcessCode())) {
body.setProcessCode("");
}
mesXslDingProcessTplService.save(body);
return Result.OK("审批模板已创建,请继续设计表单并推送到钉钉", body);
}
//update-end---author:GHT ---date:2026-06-04 for【MESToDing审批配置】新增审批模板本地草稿processCode 由后续创建钉钉模板回填)-----
@AutoLog(value = "钉钉审批模板配置-编辑")
@Operation(summary = "钉钉审批模板配置-编辑")
@RequiresPermissions("xslmes:mes_xsl_ding_process_tpl:edit")
@@ -206,22 +240,37 @@ public class MesXslDingProcessTplController extends JeecgController<MesXslDingPr
JSONObject result = ddResp.getJSONObject("result");
JSONArray processList = result == null ? null : result.getJSONArray("process_list");
// 查询本地已存在的 processCode标记哪些已导入
//update-begin---author:GHT ---date:2026-06-04 for【MESToDing审批配置】同步列表已导入判定含 processCode 与同名本地草稿-----
Set<String> existCodes = new HashSet<>();
mesXslDingProcessTplService.list().forEach(t -> existCodes.add(t.getProcessCode()));
Map<String, String> draftIdByTplName = new HashMap<>();
for (MesXslDingProcessTpl t : mesXslDingProcessTplService.list()) {
if (oConvertUtils.isNotEmpty(t.getProcessCode())) {
existCodes.add(t.getProcessCode());
} else if (oConvertUtils.isNotEmpty(t.getTplName())) {
draftIdByTplName.put(t.getTplName().trim(), t.getId());
}
}
List<Map<String, Object>> list = new ArrayList<>();
if (processList != null) {
for (int i = 0; i < processList.size(); i++) {
JSONObject item = processList.getJSONObject(i);
String code = item.getString("process_code");
String name = oConvertUtils.getString(item.getString("name"), "").trim();
Map<String, Object> row = new LinkedHashMap<>();
row.put("processCode", item.getString("process_code"));
row.put("name", item.getString("name"));
row.put("processCode", code);
row.put("name", name);
row.put("description", item.getString("description"));
row.put("imported", existCodes.contains(item.getString("process_code")));
boolean imported = oConvertUtils.isNotEmpty(code) && existCodes.contains(code);
String localDraftId = (!imported && oConvertUtils.isNotEmpty(name))
? draftIdByTplName.get(name) : null;
row.put("imported", imported || localDraftId != null);
row.put("localDraftId", localDraftId);
row.put("linkDraft", localDraftId != null && !imported);
list.add(row);
}
}
//update-end---author:GHT ---date:2026-06-04 for【MESToDing审批配置】同步列表已导入判定含 processCode 与同名本地草稿-----
return Result.OK(list);
} catch (Exception e) {
log.error("从钉钉同步审批模板失败", e);
@@ -235,28 +284,62 @@ public class MesXslDingProcessTplController extends JeecgController<MesXslDingPr
if (items == null || items.isEmpty()) {
return Result.error("请选择要导入的模板");
}
//update-begin---author:GHT ---date:2026-06-04 for【MESToDing审批配置】批量导入优先回填同名本地草稿避免重复记录-----
Set<String> existCodes = new HashSet<>();
mesXslDingProcessTplService.list().forEach(t -> existCodes.add(t.getProcessCode()));
for (MesXslDingProcessTpl t : mesXslDingProcessTplService.list()) {
if (oConvertUtils.isNotEmpty(t.getProcessCode())) {
existCodes.add(t.getProcessCode());
}
}
List<MesXslDingProcessTpl> toSave = new ArrayList<>();
int linkedCount = 0;
for (Map<String, Object> item : items) {
String code = String.valueOf(item.getOrDefault("processCode", ""));
String name = String.valueOf(item.getOrDefault("name", ""));
String code = String.valueOf(item.getOrDefault("processCode", "")).trim();
String name = String.valueOf(item.getOrDefault("name", "")).trim();
String localDraftId = item.get("localDraftId") != null ? String.valueOf(item.get("localDraftId")) : null;
if (oConvertUtils.isEmpty(code) || existCodes.contains(code)) {
continue;
}
MesXslDingProcessTpl draft = null;
if (oConvertUtils.isNotEmpty(localDraftId)) {
draft = mesXslDingProcessTplService.getById(localDraftId);
}
if (draft == null && oConvertUtils.isNotEmpty(name)) {
draft = findDraftWithoutProcessCode(name);
}
if (draft != null && oConvertUtils.isEmpty(draft.getProcessCode())) {
MesXslDingProcessTpl update = new MesXslDingProcessTpl();
update.setId(draft.getId());
update.setProcessCode(code);
if (oConvertUtils.isNotEmpty(name)) {
update.setTplName(name);
}
mesXslDingProcessTplService.updateById(update);
existCodes.add(code);
linkedCount++;
continue;
}
MesXslDingProcessTpl tpl = new MesXslDingProcessTpl();
tpl.setProcessCode(code);
tpl.setTplName(name);
tpl.setStatus("1");
tpl.setSortNo(0);
toSave.add(tpl);
existCodes.add(code);
}
if (toSave.isEmpty()) {
if (toSave.isEmpty() && linkedCount == 0) {
return Result.OK("所选模板均已存在,无需重复导入");
}
mesXslDingProcessTplService.saveBatch(toSave);
return Result.OK("成功导入 " + toSave.size() + " 个模板");
if (!toSave.isEmpty()) {
mesXslDingProcessTplService.saveBatch(toSave);
}
String msg = "成功导入 " + toSave.size() + " 个模板";
if (linkedCount > 0) {
msg += ",回填本地草稿 " + linkedCount + "";
}
return Result.OK(msg);
//update-end---author:GHT ---date:2026-06-04 for【MESToDing审批配置】批量导入优先回填同名本地草稿避免重复记录-----
}
//update-end---author:GHT ---date:2026-06-03 for【MESToDing审批配置】从钉钉同步审批模板列表-----
@@ -435,6 +518,7 @@ public class MesXslDingProcessTplController extends JeecgController<MesXslDingPr
//update-begin---author:GHT ---date:2026-06-03 for【MESToDing审批配置】创建/更新钉钉审批模板-----
@Operation(summary = "钉钉审批模板配置-创建钉钉审批模板POST不带processCode=新建)")
@RequiresPermissions("xslmes:mes_xsl_ding_process_tpl:edit")
@PostMapping(value = "/createDingTemplate")
public Result<Map<String, Object>> createDingTemplate(@RequestBody Map<String, Object> body) {
String id = String.valueOf(body.getOrDefault("id", ""));
@@ -464,13 +548,16 @@ public class MesXslDingProcessTplController extends JeecgController<MesXslDingPr
return Result.error("钉钉返回错误: " + resp.getString("message") + " (code=" + resp.getString("code") + ")");
}
String processCode = resp.getString("processCode");
if (oConvertUtils.isNotEmpty(processCode)) {
MesXslDingProcessTpl update = new MesXslDingProcessTpl();
update.setId(tpl.getId());
update.setProcessCode(processCode);
mesXslDingProcessTplService.updateById(update);
// 钉钉 v1.0 接口 processCode 在 result 节点内,非响应根字段
String processCode = extractProcessCodeFromDingFormResp(resp);
if (oConvertUtils.isEmpty(processCode)) {
log.warn("【钉钉创建模板】响应中未解析到 processCode原始响应: {}", respBody);
return Result.error("钉钉模板已提交,但未返回 processCode请从钉钉同步回填或查看后台日志");
}
MesXslDingProcessTpl update = new MesXslDingProcessTpl();
update.setId(tpl.getId());
update.setProcessCode(processCode);
mesXslDingProcessTplService.updateById(update);
Map<String, Object> result = new LinkedHashMap<>();
result.put("processCode", processCode);
@@ -483,6 +570,7 @@ public class MesXslDingProcessTplController extends JeecgController<MesXslDingPr
}
@Operation(summary = "钉钉审批模板配置-更新钉钉审批模板POST带processCode=更新已有)")
@RequiresPermissions("xslmes:mes_xsl_ding_process_tpl:edit")
@PostMapping(value = "/updateDingTemplate")
public Result<Map<String, Object>> updateDingTemplate(@RequestBody Map<String, Object> body) {
String id = String.valueOf(body.getOrDefault("id", ""));
@@ -742,6 +830,34 @@ public class MesXslDingProcessTplController extends JeecgController<MesXslDingPr
return "TextField";
}
/** 从「创建/更新审批表单」响应中解析 processCode兼容根节点与 result 节点) */
private String extractProcessCodeFromDingFormResp(JSONObject resp) {
if (resp == null) {
return null;
}
String code = resp.getString("processCode");
if (oConvertUtils.isNotEmpty(code)) {
return code;
}
JSONObject result = resp.getJSONObject("result");
if (result != null && oConvertUtils.isNotEmpty(result.getString("processCode"))) {
return result.getString("processCode");
}
return null;
}
/** 查找尚未绑定 processCode 的本地草稿(按模板名称) */
private MesXslDingProcessTpl findDraftWithoutProcessCode(String tplName) {
if (oConvertUtils.isEmpty(tplName)) {
return null;
}
QueryWrapper<MesXslDingProcessTpl> qw = new QueryWrapper<>();
qw.eq("tpl_name", tplName.trim());
qw.and(w -> w.isNull("process_code").or().eq("process_code", ""));
qw.last("LIMIT 1");
return mesXslDingProcessTplService.getOne(qw, false);
}
/** 统一 HTTP 调用钉钉 v1.0 接口 */
private String callDingApi(String method, String url, String accessToken, String jsonBody) throws Exception {
HttpClient httpClient = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(10)).build();
@@ -1093,6 +1209,20 @@ public class MesXslDingProcessTplController extends JeecgController<MesXslDingPr
// ⑤ 查询发起人在钉钉的所属部门 ID
long originatorDeptId = resolveUserDeptId(originatorDtUserId, accessToken);
//update-begin---author:GHT ---date:20260604 for【QH-MES审批台账】钉钉发起前统一门禁校验-----
String bizTable = firstNonEmpty(str(body.get("bizTable")), approvalFlow.getBizTable());
String bizDataId = str(body.get("bizDataId"));
String bizTitle = str(body.get("bizTitle"));
String bizCode = str(body.get("bizCode"));
if (oConvertUtils.isNotEmpty(bizTable) && oConvertUtils.isNotEmpty(bizDataId)) {
try {
approvalGateService.assertCanLaunch(bizTable, bizDataId, tenantId);
} catch (IllegalStateException e) {
return Result.error(e.getMessage());
}
}
//update-end---author:GHT ---date:20260604 for【QH-MES审批台账】钉钉发起前统一门禁校验-----
// ⑥ 组装钉钉发起审批请求体
JSONObject reqBody = new JSONObject();
reqBody.put("processCode", tpl.getProcessCode());
@@ -1124,8 +1254,18 @@ public class MesXslDingProcessTplController extends JeecgController<MesXslDingPr
return Result.error("钉钉返回错误: " + resp.getString("message") + " (code=" + resp.getString("code") + ")");
}
String dingInstanceId = resp.getString("instanceId");
//update-begin---author:GHT ---date:20260604 for【QH-MES审批台账】钉钉发起成功后写入审批台账-----
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));
}
//update-end---author:GHT ---date:20260604 for【QH-MES审批台账】钉钉发起成功后写入审批台账-----
Map<String, Object> result = new LinkedHashMap<>();
result.put("instanceId", resp.getString("instanceId"));
result.put("instanceId", dingInstanceId);
result.put("tplName", tpl.getTplName());
return Result.OK("审批发起成功!审批人将在钉钉「待我审批」中收到任务", result);
} catch (Exception e) {
@@ -1556,5 +1696,18 @@ public class MesXslDingProcessTplController extends JeecgController<MesXslDingPr
}
return null;
}
//update-begin---author:GHT ---date:20260604 for【QH-MES审批台账】钉钉发起参数解析辅助-----
private String str(Object value) {
return value == null ? "" : String.valueOf(value).trim();
}
private String firstNonEmpty(String first, String second) {
if (oConvertUtils.isNotEmpty(first)) {
return first.trim();
}
return oConvertUtils.isNotEmpty(second) ? second.trim() : "";
}
//update-end---author:GHT ---date:20260604 for【QH-MES审批台账】钉钉发起参数解析辅助-----
//update-end---author:GHT ---date:2026-06-03 for【MESToDing审批配置】手动填表发起钉钉审批-----------
}

View File

@@ -0,0 +1,319 @@
package org.jeecg.modules.xslmes.dingtalk.controller;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.Data;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.apache.shiro.authz.annotation.RequiresPermissions;
import org.jeecg.common.api.vo.Result;
import org.jeecg.common.aspect.annotation.AutoLog;
import org.jeecg.modules.print.catalog.IPrintBizEntityFieldCatalogProvider;
import org.jeecg.modules.print.service.IPrintBizPermEntityService;
import org.jeecg.modules.print.util.PrintBizDetailPropertyScanner;
import org.jeecg.modules.print.util.PrintBizEntityFieldIntrospector;
import org.jeecg.modules.print.vo.PrintBizDetailSlotVO;
import org.jeecg.modules.print.vo.PrintBizFieldItemVO;
import org.jeecg.modules.print.vo.PrintBizTypeVO;
import org.jeecg.modules.xslmes.dingtalk.entity.MesXslDingTplBind;
import org.jeecg.modules.xslmes.dingtalk.entity.MesXslDingProcessTpl;
import org.jeecg.modules.xslmes.dingtalk.service.IMesXslDingTplBindService;
import org.jeecg.modules.xslmes.dingtalk.service.IMesXslDingProcessTplService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.web.bind.annotation.*;
import java.util.*;
/**
* 钉钉审批模板绑定:将菜单业务实体字段与审批模板表单控件可视化绑定
*
* @author GHT
* @date 2026-06-04 for【MESToDing审批配置】审批模板绑定
*/
@Tag(name = "钉钉审批模板绑定")
@RestController
@RequestMapping("/xslmes/dingTplBind")
@Slf4j
public class MesXslDingTplBindController {
@Autowired private IMesXslDingTplBindService bindService;
@Autowired private IMesXslDingProcessTplService tplService;
@Autowired private IPrintBizPermEntityService printBizPermEntityService;
@Autowired private JdbcTemplate jdbcTemplate;
@Autowired(required = false)
private IPrintBizEntityFieldCatalogProvider fieldCatalogProvider;
// ═══════════════════════ 菜单树 ═══════════════════════
/**
* 查询 sys_permission 一二级菜单树menu_type=0/1最多两层供左侧选择业务菜单。
* 不限租户(全量展示,与打印绑定白名单选树逻辑一致)。
*/
@Operation(summary = "查询一二级菜单树(供左侧树选择)")
@GetMapping("/menuTree")
@RequiresPermissions("xslmes:mesXslDingTplBind:list")
public Result<List<MenuNodeVO>> menuTree() {
String sql = "SELECT id, parent_id, name, sort_no, menu_type, icon " +
"FROM sys_permission " +
"WHERE del_flag = 0 AND status = '1' AND menu_type IN (0, 1) " +
"ORDER BY sort_no ASC, create_time ASC";
List<Map<String, Object>> rows = jdbcTemplate.queryForList(sql);
Map<String, MenuNodeVO> nodeMap = new LinkedHashMap<>();
for (Map<String, Object> row : rows) {
MenuNodeVO n = new MenuNodeVO();
n.setId(str(row.get("id")));
n.setName(str(row.get("name")));
n.setParentId(str(row.get("parent_id")));
n.setMenuType(row.get("menu_type") != null ? ((Number) row.get("menu_type")).intValue() : 0);
n.setIcon(str(row.get("icon")));
n.setChildren(new ArrayList<>());
nodeMap.put(n.getId(), n);
}
// 构建两层树根节点parentId 为空)及其直接子节点
List<MenuNodeVO> roots = new ArrayList<>();
Set<String> rootIds = new HashSet<>();
for (MenuNodeVO n : nodeMap.values()) {
if (StringUtils.isBlank(n.getParentId()) || !nodeMap.containsKey(n.getParentId())) {
roots.add(n);
rootIds.add(n.getId());
}
}
for (MenuNodeVO n : nodeMap.values()) {
if (rootIds.contains(n.getParentId())) {
nodeMap.get(n.getParentId()).getChildren().add(n);
}
}
// 过滤掉没有子节点的根节点(纯目录空节点无意义展示)
roots.removeIf(r -> r.getChildren().isEmpty() && r.getMenuType() == 0);
return Result.OK(roots);
}
// ═══════════════════════ 绑定总览列表 ═══════════════════════
@Operation(summary = "查询所有绑定记录(按创建时间倒序)")
@GetMapping("/list")
@RequiresPermissions("xslmes:mesXslDingTplBind:list")
public Result<List<MesXslDingTplBind>> list() {
List<MesXslDingTplBind> rows = bindService.list(
new QueryWrapper<MesXslDingTplBind>()
.orderByDesc("create_time"));
return Result.OK(rows);
}
// ═══════════════════════ 模板列表 ═══════════════════════
@Operation(summary = "可用钉钉审批模板列表(状态启用)")
@GetMapping("/tplList")
@RequiresPermissions("xslmes:mesXslDingTplBind:list")
public Result<List<MesXslDingProcessTpl>> tplList() {
List<MesXslDingProcessTpl> list = tplService.list(
new QueryWrapper<MesXslDingProcessTpl>()
.eq("status", "1")
.eq("del_flag", 0)
.orderByAsc("sort_no")
.orderByDesc("create_time"));
return Result.OK(list);
}
// ═══════════════════════ 业务字段 ═══════════════════════
@Operation(summary = "按业务编码获取主实体可用字段")
@GetMapping("/bizFields")
@RequiresPermissions("xslmes:mesXslDingTplBind:list")
public Result<List<PrintBizFieldItemVO>> bizFields(@RequestParam(name = "bizCode") String bizCode) {
if (StringUtils.isBlank(bizCode)) {
return Result.error("bizCode 不能为空");
}
String code = bizCode.trim();
if (fieldCatalogProvider != null && fieldCatalogProvider.hasCatalogForBiz(code)) {
return Result.OK(fieldCatalogProvider.listMainFields(code));
}
PrintBizTypeVO vo = printBizPermEntityService.resolveBizTypeVo(code);
if (vo == null || StringUtils.isBlank(vo.getDescription())) {
return Result.OK(Collections.emptyList());
}
Class<?> cls = PrintBizEntityFieldIntrospector.tryLoadClass(vo.getDescription().trim());
if (cls == null) {
return Result.OK(Collections.emptyList());
}
return Result.OK(PrintBizEntityFieldIntrospector.listFields(cls));
}
@Operation(summary = "主实体上的明细槽位(供 TableField 绑定明细集合)")
@GetMapping("/detailSlots")
@RequiresPermissions("xslmes:mesXslDingTplBind:list")
public Result<List<PrintBizDetailSlotVO>> detailSlots(@RequestParam(name = "bizCode") String bizCode) {
if (StringUtils.isBlank(bizCode)) {
return Result.error("bizCode 不能为空");
}
String code = bizCode.trim();
if (fieldCatalogProvider != null && fieldCatalogProvider.hasCatalogForBiz(code)) {
return Result.OK(fieldCatalogProvider.listDetailSlots(code));
}
PrintBizTypeVO vo = printBizPermEntityService.resolveBizTypeVo(code);
if (vo == null || StringUtils.isBlank(vo.getDescription())) {
return Result.OK(Collections.emptyList());
}
Class<?> cls = PrintBizEntityFieldIntrospector.tryLoadClass(vo.getDescription().trim());
if (cls == null) {
return Result.OK(Collections.emptyList());
}
return Result.OK(PrintBizDetailPropertyScanner.listSlots(cls));
}
@Operation(summary = "指定明细槽位的字段列表(字段路径带属性名前缀)")
@GetMapping("/detailFields")
@RequiresPermissions("xslmes:mesXslDingTplBind:list")
public Result<List<PrintBizFieldItemVO>> detailFields(
@RequestParam(name = "bizCode") String bizCode,
@RequestParam(name = "detailProperty") String detailProperty,
@RequestParam(name = "slotKind", defaultValue = "LIST") String slotKind) {
if (StringUtils.isAnyBlank(bizCode, detailProperty)) {
return Result.error("bizCode 与 detailProperty 不能为空");
}
String code = bizCode.trim();
if (fieldCatalogProvider != null && fieldCatalogProvider.hasCatalogForBiz(code)) {
return Result.OK(fieldCatalogProvider.listPrefixedDetailFields(code, detailProperty.trim(), slotKind.trim()));
}
PrintBizTypeVO vo = printBizPermEntityService.resolveBizTypeVo(code);
if (vo == null || StringUtils.isBlank(vo.getDescription())) {
return Result.OK(Collections.emptyList());
}
Class<?> cls = PrintBizEntityFieldIntrospector.tryLoadClass(vo.getDescription().trim());
if (cls == null) {
return Result.OK(Collections.emptyList());
}
return Result.OK(PrintBizDetailPropertyScanner.listPrefixedDetailFields(cls, detailProperty.trim(), slotKind.trim()));
}
// ═══════════════════════ 按路由检测绑定(全局悬浮按钮使用) ═══════════════════════
/**
* 通过前端路由路径查找对应的审批模板绑定。
* 全局悬浮按钮在每次路由跳转时调用,若返回非 null 则显示"发起钉钉审批"按钮。
*/
@Operation(summary = "按前端路由路径检测是否有钉钉绑定null=无)")
@GetMapping("/bindingByRoute")
@RequiresPermissions("xslmes:mesXslDingTplBind:list")
public Result<MesXslDingTplBind> bindingByRoute(@RequestParam(name = "routePath") String routePath) {
if (StringUtils.isBlank(routePath)) {
return Result.OK(null);
}
String path = routePath.trim();
String sql = "SELECT id FROM sys_permission WHERE url = ? AND del_flag = 0 LIMIT 1";
List<String> ids;
try {
ids = jdbcTemplate.queryForList(sql, String.class, path);
} catch (Exception e) {
return Result.OK(null);
}
if (ids.isEmpty()) {
return Result.OK(null);
}
MesXslDingTplBind bind = bindService.getByBizCode(ids.get(0));
return Result.OK(bind);
}
// ═══════════════════════ 绑定 CRUD ═══════════════════════
@Operation(summary = "按业务编码查询已保存的绑定配置")
@GetMapping("/getByBizCode")
@RequiresPermissions("xslmes:mesXslDingTplBind:list")
public Result<MesXslDingTplBind> getByBizCode(@RequestParam(name = "bizCode") String bizCode) {
if (StringUtils.isBlank(bizCode)) {
return Result.error("bizCode 不能为空");
}
MesXslDingTplBind row = bindService.getByBizCode(bizCode.trim());
return row != null ? Result.OK(row) : Result.OK(null);
}
@AutoLog(value = "钉钉审批模板绑定-保存")
@Operation(summary = "保存绑定配置(新增或更新)")
@PostMapping("/save")
@RequiresPermissions("xslmes:mesXslDingTplBind:save")
public Result<String> save(@RequestBody SaveBindRequest req) {
if (req == null || StringUtils.isBlank(req.getBizCode())) {
return Result.error("业务编码不能为空");
}
if (StringUtils.isBlank(req.getTemplateId())) {
return Result.error("请选择钉钉审批模板");
}
MesXslDingProcessTpl tpl = tplService.getById(req.getTemplateId());
if (tpl == null) {
return Result.error("选择的钉钉审批模板不存在");
}
MesXslDingTplBind existing = bindService.getByBizCode(req.getBizCode().trim());
if (existing != null) {
// 更新已有绑定
existing.setTemplateId(req.getTemplateId());
existing.setTemplateName(tpl.getTplName());
if (StringUtils.isNotBlank(req.getBizName())) {
existing.setBizName(req.getBizName());
}
existing.setFieldMappingJson(normalizeJson(req.getFieldMappingJson()));
bindService.updateById(existing);
return Result.OK("更新成功");
}
// 新增
MesXslDingTplBind bind = new MesXslDingTplBind();
bind.setBizCode(req.getBizCode().trim());
bind.setBizName(req.getBizName());
bind.setTemplateId(req.getTemplateId());
bind.setTemplateName(tpl.getTplName());
bind.setFieldMappingJson(normalizeJson(req.getFieldMappingJson()));
bindService.save(bind);
return Result.OK("保存成功");
}
@AutoLog(value = "钉钉审批模板绑定-删除")
@Operation(summary = "删除绑定配置")
@DeleteMapping("/delete")
@RequiresPermissions("xslmes:mesXslDingTplBind:delete")
public Result<String> delete(@RequestParam(name = "id") String id) {
bindService.removeById(id);
return Result.OK("删除成功");
}
// ═══════════════════════ 辅助方法 ═══════════════════════
private static String str(Object o) {
return o == null ? null : o.toString();
}
/** 确保 mapping JSON 为数组字符串,避免空或非法格式入库 */
private static String normalizeJson(String raw) {
if (StringUtils.isBlank(raw)) {
return "[]";
}
String trimmed = raw.trim();
if (trimmed.startsWith("[")) {
return trimmed;
}
return "[]";
}
// ═══════════════════════ 内部 VO ═══════════════════════
@Data
public static class MenuNodeVO {
private String id;
private String name;
private String parentId;
private Integer menuType;
private String icon;
private List<MenuNodeVO> children;
}
@Data
public static class SaveBindRequest {
private String bizCode;
private String bizName;
private String templateId;
private String fieldMappingJson;
}
}

View File

@@ -0,0 +1,52 @@
package org.jeecg.modules.xslmes.dingtalk.entity;
import com.baomidou.mybatisplus.annotation.TableLogic;
import com.baomidou.mybatisplus.annotation.TableName;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.experimental.Accessors;
import org.jeecg.common.system.base.entity.JeecgEntity;
import java.io.Serializable;
/**
* 钉钉审批模板绑定(菜单业务实体 ↔ 钉钉审批模板表单字段)
*
* @author GHT
* @date 2026-06-04 for【MESToDing审批配置】审批模板绑定
*/
@Data
@TableName("mes_xsl_ding_tpl_bind")
@Accessors(chain = true)
@EqualsAndHashCode(callSuper = false)
@Schema(description = "钉钉审批模板绑定")
public class MesXslDingTplBind extends JeecgEntity implements Serializable {
private static final long serialVersionUID = 1L;
@Schema(description = "业务编码(sys_permission.id与打印绑定 biz_code 含义一致)")
private String bizCode;
@Schema(description = "业务名称(菜单名)")
private String bizName;
@Schema(description = "钉钉审批模板ID(mes_xsl_ding_process_tpl.id)")
private String templateId;
@Schema(description = "钉钉审批模板名称")
private String templateName;
@Schema(description = "字段绑定JSON[{componentId,componentLabel,componentName,parentId,bizField}]")
private String fieldMappingJson;
@Schema(description = "逻辑删除:0正常 1已删除")
@TableLogic
private Integer delFlag;
@Schema(description = "租户ID")
private Integer tenantId;
@Schema(description = "所属部门编码")
private String sysOrgCode;
}

View File

@@ -0,0 +1,13 @@
package org.jeecg.modules.xslmes.dingtalk.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import org.jeecg.modules.xslmes.dingtalk.entity.MesXslDingTplBind;
/**
* 钉钉审批模板绑定 Mapper
*
* @author GHT
* @date 2026-06-04
*/
public interface MesXslDingTplBindMapper extends BaseMapper<MesXslDingTplBind> {
}

View File

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="org.jeecg.modules.xslmes.dingtalk.mapper.MesXslDingTplBindMapper">
</mapper>

View File

@@ -0,0 +1,16 @@
package org.jeecg.modules.xslmes.dingtalk.service;
import com.baomidou.mybatisplus.extension.service.IService;
import org.jeecg.modules.xslmes.dingtalk.entity.MesXslDingTplBind;
/**
* 钉钉审批模板绑定 Service
*
* @author GHT
* @date 2026-06-04
*/
public interface IMesXslDingTplBindService extends IService<MesXslDingTplBind> {
/** 按业务编码查询绑定记录(未删除的第一条) */
MesXslDingTplBind getByBizCode(String bizCode);
}

View File

@@ -0,0 +1,24 @@
package org.jeecg.modules.xslmes.dingtalk.service.impl;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import org.jeecg.modules.xslmes.dingtalk.entity.MesXslDingTplBind;
import org.jeecg.modules.xslmes.dingtalk.mapper.MesXslDingTplBindMapper;
import org.jeecg.modules.xslmes.dingtalk.service.IMesXslDingTplBindService;
import org.springframework.stereotype.Service;
/**
* 钉钉审批模板绑定 ServiceImpl
*
* @author GHT
* @date 2026-06-04
*/
@Service
public class MesXslDingTplBindServiceImpl extends ServiceImpl<MesXslDingTplBindMapper, MesXslDingTplBind>
implements IMesXslDingTplBindService {
@Override
public MesXslDingTplBind getByBizCode(String bizCode) {
return getOne(new QueryWrapper<MesXslDingTplBind>().eq("biz_code", bizCode).last("LIMIT 1"));
}
}

View File

@@ -0,0 +1,352 @@
package org.jeecg.modules.xslmes.dingtalk.stream;
import com.alibaba.fastjson2.JSONArray;
import com.alibaba.fastjson2.JSONObject;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import lombok.extern.slf4j.Slf4j;
import org.jeecg.common.util.oConvertUtils;
import org.jeecg.modules.xslmes.approval.callback.ApprovalCallbackContext;
import org.jeecg.modules.xslmes.approval.callback.ApprovalCallbackDispatcher;
import org.jeecg.modules.xslmes.approval.callback.ApprovalActionHttpExecutor;
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.service.IMesXslApprovalGateService;
import org.jeecg.modules.xslmes.approval.service.IMesXslApprovalRecordService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Component;
import java.util.ArrayList;
import java.util.List;
/**
* 钉钉审批事件处理器。
* <p>
* 收到 Stream 事件后,主动调用 {@link DingTalkWorkflowService#getProcessInstance} 拉取完整实例数据,
* 通过 {@code operationRecords} 精准得知"哪个节点由谁操作",从而:
* <ul>
* <li>用审批人自己的 JWT Token 调用业务接口proofread_by/audit_by 写入真实操作人);</li>
* <li>以 operationRecord 的顺序索引映射到 MES 流程节点,执行节点配置的 callbackActions</li>
* <li>同时触发 {@link org.jeecg.modules.xslmes.approval.callback.IApprovalBizCallback} Bean 回调。</li>
* </ul>
*
* @author GHT
* @date 2026-06-04 for【钉钉Stream回调】基于operationRecords精准映射节点与操作人
*/
@Slf4j
@Component
public class DingBpmsEventProcessor {
@Autowired
private IMesXslApprovalGateService approvalGateService;
@Autowired
private IMesXslApprovalRecordService approvalRecordService;
@Autowired
private IMesXslApprovalFlowService approvalFlowService;
@Autowired
private ApprovalCallbackDispatcher callbackDispatcher;
@Autowired
private ApprovalActionHttpExecutor actionHttpExecutor;
@Autowired
private DingTalkWorkflowService workflowService;
@Autowired
private JdbcTemplate jdbcTemplate;
// ==================== bpms_instance_change ====================
//update-begin---author:GHT ---date:20260604 for【钉钉Stream回调】拉取实例详情后精准执行节点回调-----
public void onInstanceChange(JSONObject data) {
if (data == null) return;
String processInstanceId = data.getString("processInstanceId");
String type = data.getString("type");
String result = data.getString("result");
log.info("[DingBpms] bpms_instance_change instanceId={} type={} result={}",
processInstanceId, type, result);
if (oConvertUtils.isEmpty(processInstanceId) || "start".equals(type)) {
return;
}
// ① 映射终态
String status;
String remark;
if ("finish".equals(type)) {
if ("agree".equals(result)) {
status = ApprovalRecordConstants.STATUS_APPROVED;
remark = "钉钉审批通过";
} else if ("refuse".equals(result)) {
status = ApprovalRecordConstants.STATUS_REJECTED;
remark = "钉钉审批拒绝";
} else {
log.info("[DingBpms] 审批转交 result={},不处理 instanceId={}", result, processInstanceId);
return;
}
} else if ("terminate".equals(type)) {
status = ApprovalRecordConstants.STATUS_CANCELLED;
remark = "钉钉审批已终止";
} else {
return;
}
//update-begin---author:GHT ---date:2026-06-04 for【20260604】钉钉回调幂等去重finishByExternalInstance条件为status=RUNNING0行更新即终态已处理-----
// ② 更新台账乐观条件WHERE status=RUNNING返回 false 表示已是终态,本次为重复事件,直接跳过)
try {
boolean updated = approvalGateService.finishByExternalInstance(
ApprovalRecordConstants.CHANNEL_DINGTALK, processInstanceId, status, remark);
if (!updated) {
log.info("[DingBpms] instanceId={} 台账已是终态,跳过重复的终态事件", processInstanceId);
return;
}
log.info("[DingBpms] 台账已更新 instanceId={} -> status={}", processInstanceId, status);
} catch (Exception e) {
log.error("[DingBpms] 台账更新失败 instanceId={}: {}", processInstanceId, e.getMessage(), e);
return;
}
//update-end---author:GHT ---date:2026-06-04 for【20260604】钉钉回调幂等去重finishByExternalInstance条件为status=RUNNING0行更新即终态已处理-----
if (ApprovalRecordConstants.STATUS_CANCELLED.equals(status)) {
return;
}
// ③ 拉取完整审批实例
MesXslApprovalRecord record = findRecord(processInstanceId);
if (record == null || oConvertUtils.isEmpty(record.getBizTable())) {
return;
}
JSONObject instance = workflowService.getProcessInstance(processInstanceId);
List<JSONObject> taskOps = workflowService.getTaskOperations(instance);
List<JSONObject> mesNodes = loadApproverNodes(record.getFlowId());
ApprovalCallbackContext ctx = buildContext(record, remark);
if (ApprovalRecordConstants.STATUS_APPROVED.equals(status)) {
// 最终通过:执行最后一个节点的 onApprove用最后审批人的 token
if (!mesNodes.isEmpty() && !taskOps.isEmpty()) {
JSONObject lastOp = taskOps.get(taskOps.size() - 1);
String lastDtUserId = lastOp.getString("userId");
String token = workflowService.generateTokenByDtUserId(lastDtUserId);
JSONObject lastNode = mesNodes.get(mesNodes.size() - 1);
actionHttpExecutor.run(lastNode, "onApprove", record.getBizDataId(), token);
}
callbackDispatcher.fireApproved(ctx);
} else {
// 驳回:复用与 MES 内部审批相同的 isBizAtOriginStatus 逻辑
// 台账已在发起时快照了 originStatus若单据状态仍为原值说明未被推进跳过 onReject
JSONObject refuseOp = findRefuseOp(taskOps);
if (refuseOp != null) {
int refuseIndex = taskOps.indexOf(refuseOp);
boolean bizAtOrigin = isBizAtOriginStatus(record);
if (!bizAtOrigin && refuseIndex < mesNodes.size()) {
// 单据已被前面节点推进,需要调 onReject 回退
String dtUserId = refuseOp.getString("userId");
String token = workflowService.generateTokenByDtUserId(dtUserId);
try {
actionHttpExecutor.run(mesNodes.get(refuseIndex), "onReject",
record.getBizDataId(), token);
} catch (Exception e) {
log.error("[DingBpms] onReject HTTP 回调失败: {}", e.getMessage());
}
} else {
log.info("[DingBpms] 单据仍处于发起前原始状态,跳过 onReject 回调 instanceId={}",
processInstanceId);
}
}
callbackDispatcher.fireRejected(ctx);
}
log.info("[DingBpms] 终态回调完成 bizTable={} bizDataId={} status={}",
record.getBizTable(), record.getBizDataId(), status);
}
//update-end---author:GHT ---date:20260604 for【钉钉Stream回调】拉取实例详情后精准执行节点回调-----
// ==================== bpms_task_change ====================
//update-begin---author:GHT ---date:20260604 for【钉钉Stream回调】节点通过时按operationRecords索引执行onNodeApprove-----
/**
* 节点任务变更:每个审批人操作时触发。
* <p>
* 节点通过时:
* <ol>
* <li>拉取审批实例详情,从 operationRecords 得到"已完成任务列表"</li>
* <li>最后一条完成操作的索引 = 本次节点在 MES 流程中的位置;</li>
* <li>用该操作人的 Token 执行对应 MES 节点的 onNodeApprove 回调接口;</li>
* <li>触发 IApprovalBizCallback.onNodeApproved。</li>
* </ol>
*/
public void onTaskChange(JSONObject data) {
if (data == null) return;
String processInstanceId = data.getString("processInstanceId");
String type = data.getString("type");
String result = data.getString("result");
String actionerDtUserId = data.getString("actionerUserId");
log.info("[DingBpms] bpms_task_change instanceId={} type={} result={} actionerUserId={}",
processInstanceId, type, result, actionerDtUserId);
// 只处理节点"完成-通过"
if (!"finish".equals(type) || !"agree".equals(result)) {
// 拒绝终态由 bpms_instance_change 统一处理,此处不重复触发
return;
}
MesXslApprovalRecord record = findRecord(processInstanceId);
if (record == null || oConvertUtils.isEmpty(record.getBizTable())) {
return;
}
// 拉取实例详情
JSONObject instance = workflowService.getProcessInstance(processInstanceId);
if (instance == null) {
log.warn("[DingBpms] 获取审批实例详情失败 instanceId={},跳过节点回调", processInstanceId);
return;
}
List<JSONObject> taskOps = workflowService.getTaskOperations(instance);
List<JSONObject> mesNodes = loadApproverNodes(record.getFlowId());
if (taskOps.isEmpty() || mesNodes.isEmpty()) {
return;
}
// 刚完成的是最后一条操作index = taskOps.size()-1
int nodeIndex = taskOps.size() - 1;
if (nodeIndex >= mesNodes.size()) {
log.debug("[DingBpms] 节点索引 {} 超出 MES 节点数 {},跳过", nodeIndex, mesNodes.size());
return;
}
//update-begin---author:GHT ---date:2026-06-04 for【20260604】钉钉回调幂等去重DB乐观锁推进processed_op_count并发安全且重启不丢-----
// tryMarkNodeProcessedUPDATE ... SET processed_op_count=nodeIndex+1 WHERE processed_op_count<nodeIndex+1
// MySQL 行锁保证原子性:并发两个事件只有一个成功,另一个返回 false
boolean claimed = approvalGateService.tryMarkNodeProcessed(record.getId(), nodeIndex);
if (!claimed) {
log.info("[DingBpms] 节点{} 回调已执行,跳过重复事件 instanceId={}", nodeIndex + 1, processInstanceId);
return;
}
//update-end---author:GHT ---date:2026-06-04 for【20260604】钉钉回调幂等去重DB乐观锁推进processed_op_count并发安全且重启不丢-----
// 操作人:优先用 operationRecords 里的 userId兜底用事件里的 actionerUserId
JSONObject lastOp = taskOps.get(nodeIndex);
String dtUserId = oConvertUtils.isNotEmpty(lastOp.getString("userId"))
? lastOp.getString("userId") : actionerDtUserId;
String actioner = oConvertUtils.isNotEmpty(lastOp.getString("showName"))
? lastOp.getString("showName") : "审批人";
String token = workflowService.generateTokenByDtUserId(dtUserId);
JSONObject node = mesNodes.get(nodeIndex);
// 执行该节点的 onNodeApprove HTTP 回调
actionHttpExecutor.run(node, "onNodeApprove", record.getBizDataId(), token);
// 触发 IApprovalBizCallback.onNodeApproved
try {
ApprovalCallbackContext ctx = buildContext(record, "钉钉节点审批通过(" + actioner + "")
.setOperatorName(actioner);
callbackDispatcher.fireNodeApproved(ctx);
} catch (Exception e) {
log.error("[DingBpms] 节点 Bean 回调失败 instanceId={}: {}", processInstanceId, e.getMessage(), e);
}
log.info("[DingBpms] 节点{}/{} 回调完成 actioner={} bizTable={} bizDataId={}",
nodeIndex + 1, mesNodes.size(), actioner, record.getBizTable(), record.getBizDataId());
}
//update-end---author:GHT ---date:20260604 for【钉钉Stream回调】节点通过时按operationRecords索引执行onNodeApprove-----
// ==================== 内部辅助 ====================
private MesXslApprovalRecord findRecord(String processInstanceId) {
if (oConvertUtils.isEmpty(processInstanceId)) return null;
List<MesXslApprovalRecord> list = approvalRecordService.list(
new LambdaQueryWrapper<MesXslApprovalRecord>()
.eq(MesXslApprovalRecord::getExternalInstanceId, processInstanceId)
.eq(MesXslApprovalRecord::getChannel, ApprovalRecordConstants.CHANNEL_DINGTALK)
.orderByDesc(MesXslApprovalRecord::getCreateTime)
.last("LIMIT 1"));
return list.isEmpty() ? null : list.get(0);
}
private List<JSONObject> loadApproverNodes(String flowId) {
List<JSONObject> result = new ArrayList<>();
if (oConvertUtils.isEmpty(flowId)) return result;
try {
MesXslApprovalFlow flow = approvalFlowService.getById(flowId);
if (flow == null || oConvertUtils.isEmpty(flow.getFlowConfig())) return result;
collectApproverNodes(JSONObject.parseObject(flow.getFlowConfig()), result);
} catch (Exception e) {
log.warn("[DingBpms] 加载流程节点失败 flowId={}: {}", flowId, e.getMessage());
}
return result;
}
private void collectApproverNodes(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);
}
}
collectApproverNodes(node.getJSONObject("childNode"), out);
}
/** 找第一条 result=REFUSE 的操作记录 */
private JSONObject findRefuseOp(List<JSONObject> taskOps) {
for (JSONObject op : taskOps) {
String r = op.getString("result");
if ("REFUSE".equalsIgnoreCase(r) || "refuse".equalsIgnoreCase(r)) {
return op;
}
}
return null;
}
/**
* 判断业务单据是否仍处于发起审批前的原始状态。
* 与 MesXslApprovalHandleServiceImpl.isBizAtOriginStatus(MesXslApprovalInstance) 逻辑完全一致,
* 此处基于 MesXslApprovalRecord钉钉通道实现使驳回逻辑在两通道间真正复用。
*/
private boolean isBizAtOriginStatus(MesXslApprovalRecord record) {
String statusField = record.getStatusField();
String originStatus = record.getOriginStatus();
if (oConvertUtils.isEmpty(statusField) || originStatus == null) {
// 无快照信息(旧数据兼容):退化为 false走 onReject 调用
return false;
}
if (!record.getBizTable().matches("^[A-Za-z0-9_]+$")
|| !statusField.matches("^[A-Za-z0-9_]+$")) {
return false;
}
try {
List<String> vals = jdbcTemplate.queryForList(
"SELECT " + statusField + " FROM " + record.getBizTable()
+ " WHERE id=? LIMIT 1", String.class, record.getBizDataId());
return !vals.isEmpty() && java.util.Objects.equals(originStatus, vals.get(0));
} catch (Exception e) {
log.warn("[DingBpms] 读取业务状态失败 table={}: {}", record.getBizTable(), e.getMessage());
return false;
}
}
private ApprovalCallbackContext buildContext(MesXslApprovalRecord record, String comment) {
return new ApprovalCallbackContext()
.setInstanceId(record.getId())
.setFlowId(record.getFlowId())
.setFlowName(record.getFlowName())
.setBizTable(record.getBizTable())
.setBizTableName(record.getBizTableName())
.setBizDataId(record.getBizDataId())
.setBizTitle(record.getBizTitle())
.setApplyUser(record.getApplyUser())
.setComment(comment)
.setOperatorUsername("dingtalk")
.setOperatorName("钉钉审批");
}
}

View File

@@ -0,0 +1,151 @@
package org.jeecg.modules.xslmes.dingtalk.stream;
import com.dingtalk.open.app.api.GenericEventListener;
import com.dingtalk.open.app.api.OpenDingTalkStreamClientBuilder;
import com.dingtalk.open.app.api.message.GenericOpenDingTalkEvent;
import com.dingtalk.open.app.api.security.AuthClientCredential;
import com.dingtalk.open.app.stream.protocol.event.EventAckStatus;
import lombok.extern.slf4j.Slf4j;
import org.jeecg.common.util.oConvertUtils;
import org.jeecg.modules.system.service.impl.ThirdAppDingtalkServiceImpl;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.SmartLifecycle;
import org.springframework.stereotype.Component;
/**
* 钉钉 Stream 模式事件接收客户端(基于官方 SDK com.dingtalk.open:dingtalk-stream
* <p>
* 无需注册公网回调地址:应用主动建立长连接,钉钉通过该通道推送事件(如审批结果),
* 官方 SDK 内部自动维护重连与心跳。
* <p>
* 启动时机:{@link SmartLifecycle}phase=MAX-100确保 Spring 上下文完全就绪后再建连。
*
* @author GHT
* @date 2026-06-04 for【钉钉Stream回调】基于官方SDK的Stream客户端
*/
@Slf4j
@Component
public class DingTalkStreamClient implements SmartLifecycle {
@Autowired
private ThirdAppDingtalkServiceImpl dingtalkService;
@Autowired
private DingBpmsEventProcessor bpmsEventProcessor;
private volatile boolean running = false;
// ==================== SmartLifecycle ====================
//update-begin---author:GHT ---date:20260604 for【钉钉Stream回调】应用启动后自动建立Stream连接-----
@Override
public int getPhase() {
// 最后启动,确保 DB / Spring Bean 全部就绪
return Integer.MAX_VALUE - 100;
}
@Override
public void start() {
running = true;
// 在后台线程初始化,避免阻塞 Spring 上下文刷新
Thread t = new Thread(this::initSdkClient, "ding-stream");
t.setDaemon(true);
t.start();
}
@Override
public void stop() {
running = false;
// SDK 内部使用 daemon 线程JVM 退出时自动终止
log.info("[DingStream] 钉钉 Stream 客户端已停止");
}
@Override
public boolean isRunning() {
return running;
}
//update-end---author:GHT ---date:20260604 for【钉钉Stream回调】应用启动后自动建立Stream连接-----
// ==================== SDK 初始化(官方写法)====================
//update-begin---author:GHT ---date:20260604 for【钉钉Stream回调】使用官方SDK建立Stream长连接-----
private void initSdkClient() {
try {
String[] creds = dingtalkService.getDingAppCredentials();
if (creds == null || oConvertUtils.isEmpty(creds[0]) || oConvertUtils.isEmpty(creds[1])) {
log.warn("[DingStream] 钉钉 AppKey/AppSecret 未配置Stream 连接未启动。"
+ "请在【系统配置-第三方应用】中完成钉钉应用配置后重启服务。");
return;
}
log.info("[DingStream] 正在建立钉钉 Stream 连接AppKey={}", creds[0]);
// 官方写法build().start() 链式调用SDK 内部管理长连接与重连
OpenDingTalkStreamClientBuilder
.custom()
.credential(new AuthClientCredential(creds[0], creds[1]))
.registerAllEventListener(new GenericEventListener() {
@Override
public EventAckStatus onEvent(GenericOpenDingTalkEvent event) {
return handleEvent(event);
}
})
.build()
.start();
log.info("[DingStream] 钉钉 Stream 客户端已启动,等待审批事件推送");
} catch (Exception e) {
log.error("[DingStream] SDK 启动失败,请检查钉钉配置: {}", e.getMessage(), e);
}
}
//update-end---author:GHT ---date:20260604 for【钉钉Stream回调】使用官方SDK建立Stream长连接-----
// ==================== 事件处理 ====================
//update-begin---author:GHT ---date:20260604 for【钉钉Stream回调】处理事件并回写审批台账-----
private EventAckStatus handleEvent(GenericOpenDingTalkEvent event) {
try {
String eventType = event.getEventType();
log.debug("[DingStream] 收到事件 eventId={} eventType={} bornTime={}",
event.getEventId(), eventType, event.getEventBornTime());
if (!"bpms_instance_change".equals(eventType) && !"bpms_task_change".equals(eventType)) {
return EventAckStatus.SUCCESS;
}
// getData() 返回 fastjson2 JSONObject
com.alibaba.fastjson2.JSONObject data = toJsonObject(event.getData());
if (data == null) {
log.warn("[DingStream] 事件 data 为空eventType={}", eventType);
return EventAckStatus.SUCCESS;
}
if ("bpms_instance_change".equals(eventType)) {
bpmsEventProcessor.onInstanceChange(data);
} else {
bpmsEventProcessor.onTaskChange(data);
}
return EventAckStatus.SUCCESS;
} catch (Exception e) {
log.error("[DingStream] 事件处理异常 eventType={}: {}", event.getEventType(), e.getMessage(), e);
// LATER通知钉钉稍后重推避免丢失事件
return EventAckStatus.LATER;
}
}
private static com.alibaba.fastjson2.JSONObject toJsonObject(Object raw) {
if (raw == null) return null;
if (raw instanceof com.alibaba.fastjson2.JSONObject) {
return (com.alibaba.fastjson2.JSONObject) raw;
}
try {
return com.alibaba.fastjson2.JSONObject.parseObject(String.valueOf(raw));
} catch (Exception e) {
return null;
}
}
//update-end---author:GHT ---date:20260604 for【钉钉Stream回调】处理事件并回写审批台账-----
}

View File

@@ -0,0 +1,204 @@
package org.jeecg.modules.xslmes.dingtalk.stream;
import com.alibaba.fastjson2.JSONArray;
import com.alibaba.fastjson2.JSONObject;
import lombok.extern.slf4j.Slf4j;
import org.jeecg.common.constant.CommonConstant;
import org.jeecg.common.system.util.JwtUtil;
import org.jeecg.common.util.RedisUtil;
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.springframework.beans.factory.annotation.Autowired;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Service;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.time.Duration;
import java.util.ArrayList;
import java.util.List;
/**
* 钉钉审批工作流 API 封装。
* 提供「获取审批实例详情」和「审批人身份 Token 生成」两个核心能力,
* 供 {@link DingBpmsEventProcessor} 在收到事件后主动查询完整实例数据。
*
* @author GHT
* @date 2026-06-04 for【钉钉Stream回调】主动拉取审批实例详情精准映射节点与操作人
*/
@Slf4j
@Service
public class DingTalkWorkflowService {
private static final String PROCESS_INSTANCE_URL =
"https://api.dingtalk.com/v1.0/workflow/processInstances";
@Autowired
private ThirdAppDingtalkServiceImpl dingtalkService;
@Autowired
private ISysUserService sysUserService;
@Autowired
private JdbcTemplate jdbcTemplate;
@Autowired
private RedisUtil redisUtil;
// ==================== 审批实例详情 ====================
//update-begin---author:GHT ---date:20260604 for【钉钉Stream回调】拉取钉钉审批实例详情-----
/**
* 调用 GET /v1.0/workflow/processInstances 获取审批实例完整数据。
*
* @param processInstanceId 钉钉审批实例 ID
* @return result 节点(含 status/result/operationRecords/tasks/formComponentValues 等),失败返回 null
*/
public JSONObject getProcessInstance(String processInstanceId) {
if (oConvertUtils.isEmpty(processInstanceId)) {
return null;
}
// 后台线程无 TenantContext必须用绕过租户校验的专用方法
String accessToken = dingtalkService.getAccessTokenForBackground();
if (oConvertUtils.isEmpty(accessToken)) {
log.warn("[DingWorkflow] AccessToken 获取失败,无法查询审批实例 {}", 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);
if (resp.containsKey("code")) {
log.warn("[DingWorkflow] 查询审批实例失败 instanceId={} code={} msg={}",
processInstanceId, resp.getString("code"), resp.getString("message"));
return null;
}
return resp.getJSONObject("result");
} catch (Exception e) {
log.error("[DingWorkflow] 调用钉钉审批实例详情接口异常 instanceId={}: {}",
processInstanceId, e.getMessage(), e);
return null;
}
}
//update-end---author:GHT ---date:20260604 for【钉钉Stream回调】拉取钉钉审批实例详情-----
// ==================== operationRecords 解析 ====================
//update-begin---author:GHT ---date:20260604 for【钉钉Stream回调】从operationRecords提取节点操作序列-----
/**
* 从审批实例的 operationRecords 中提取所有"正常任务执行"记录type=EXECUTE_TASK_NORMAL
* 按时间顺序排列,每条记录对应一个审批节点的操作(通过/拒绝)。
* <p>
* 返回的列表索引与 MES 流程节点列表索引一一对应第0条 = 第1个节点第1条 = 第2个节点以此类推。
*/
public List<JSONObject> getTaskOperations(JSONObject instanceResult) {
List<JSONObject> ops = new ArrayList<>();
if (instanceResult == null) {
return ops;
}
JSONArray records = instanceResult.getJSONArray("operationRecords");
if (records == null || records.isEmpty()) {
return ops;
}
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)) {
ops.add(rec);
}
}
return ops;
}
//update-end---author:GHT ---date:20260604 for【钉钉Stream回调】从operationRecords提取节点操作序列-----
// ==================== 操作人身份 Token ====================
//update-begin---author:GHT ---date:20260604 for【钉钉Stream回调】按钉钉userId生成操作人JWT Token-----
/**
* 将钉钉 userId 映射到 MES 系统用户,并生成该用户的 JWT Token。
* <p>
* 查询链sys_third_account(third_user_id=dtUserId) → sys_user_id → sys_user.username/password
* <p>
* 这样回调业务接口时,接口内部通过 {@code SecurityUtils.getSubject().getPrincipal()}
* 拿到的就是真实审批人,而非 admin保证 proofread_by/audit_by/approve_by 字段写入正确。
*
* @param dtUserId 钉钉 userId来自 operationRecord.userId
* @return JWT token找不到绑定或发生异常时返回 null调用方降级用 admin token
*/
public String generateTokenByDtUserId(String dtUserId) {
if (oConvertUtils.isEmpty(dtUserId)) {
return null;
}
try {
// ① 钉钉userId → MES sys_user_id
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);
if (userIds.isEmpty() || oConvertUtils.isEmpty(userIds.get(0))) {
log.debug("[DingWorkflow] 钉钉用户 {} 未绑定 MES 账号,降级使用 admin token", dtUserId);
return generateAdminToken();
}
// ② sys_user_id → username + password
SysUser user = sysUserService.getById(userIds.get(0));
if (user == null || oConvertUtils.isEmpty(user.getPassword())) {
return generateAdminToken();
}
return signAndCache(user.getUsername(), user.getPassword());
} catch (Exception e) {
log.warn("[DingWorkflow] 生成用户 token 失败 dtUserId={}: {}", dtUserId, e.getMessage());
return generateAdminToken();
}
}
/** 生成 admin 系统 token用于钉钉用户未绑定 MES 账号时的兜底 */
public String generateAdminToken() {
try {
SysUser admin = sysUserService.getUserByName("admin");
if (admin == null || oConvertUtils.isEmpty(admin.getPassword())) {
log.warn("[DingWorkflow] admin 用户不存在,无法生成系统 token");
return null;
}
return signAndCache(admin.getUsername(), admin.getPassword());
} catch (Exception e) {
log.warn("[DingWorkflow] 生成 admin token 失败: {}", e.getMessage());
return null;
}
}
//update-begin---author:GHT ---date:20260604 for【钉钉Stream回调】生成token后写入Redis通过Shiro校验-----
/**
* 生成 JWT Token 并写入 Rediskey=PREFIX_USER_TOKEN+tokenvalue=token
* <p>
* JeecgBoot Shiro 在 {@code jwtTokenRefresh()} 中验证 token 时,
* 必须从 Redis {@code "prefix_user_token:" + token} 取到缓存值,
* 否则报 "Token失效请重新登录"。新生成的 token 需要手动写入 Redis 才能通过校验。
* <p>
* TTL 设置为 JwtUtil.EXPIRE_TIME / 1000与正常登录保持一致。
*/
private String signAndCache(String username, String password) {
String token = JwtUtil.sign(username, password);
if (oConvertUtils.isNotEmpty(token)) {
// 写入 Redis让 Shiro jwtTokenRefresh 能查到
long ttlSeconds = JwtUtil.EXPIRE_TIME / 1000;
redisUtil.set(CommonConstant.PREFIX_USER_TOKEN + token, token, ttlSeconds);
}
return token;
}
//update-end---author:GHT ---date:20260604 for【钉钉Stream回调】生成token后写入Redis通过Shiro校验-----
//update-end---author:GHT ---date:20260604 for【钉钉Stream回调】按钉钉userId生成操作人JWT Token-----
}

View File

@@ -1,4 +1,4 @@
package org.jeecg.modules.xslmes.mcs.controller;
package org.jeecg.modules.xslmes.mcs.controller;
import jakarta.servlet.http.HttpServletRequest;
import org.jeecg.common.api.vo.Result;

View File

@@ -1,4 +1,4 @@
package org.jeecg.modules.xslmes.mcs.controller;
package org.jeecg.modules.xslmes.mcs.controller;
import jakarta.servlet.http.HttpServletRequest;
import org.jeecg.common.api.vo.Result;

View File

@@ -1,4 +1,4 @@
package org.jeecg.modules.xslmes.mcs.controller;
package org.jeecg.modules.xslmes.mcs.controller;
import jakarta.servlet.http.HttpServletRequest;
import org.jeecg.common.api.vo.Result;

View File

@@ -1,4 +1,4 @@
package org.jeecg.modules.xslmes.mcs.controller;
package org.jeecg.modules.xslmes.mcs.controller;
import jakarta.servlet.http.HttpServletRequest;
import org.jeecg.common.api.vo.Result;

View File

@@ -1,4 +1,4 @@
package org.jeecg.modules.xslmes.mcs.controller;
package org.jeecg.modules.xslmes.mcs.controller;
import jakarta.servlet.http.HttpServletRequest;
import org.jeecg.common.api.vo.Result;

View File

@@ -1,4 +1,4 @@
package org.jeecg.modules.xslmes.mcs.controller;
package org.jeecg.modules.xslmes.mcs.controller;
import jakarta.servlet.http.HttpServletRequest;
import org.jeecg.common.api.vo.Result;

View File

@@ -1,4 +1,4 @@
package org.jeecg.modules.xslmes.mcs.controller;
package org.jeecg.modules.xslmes.mcs.controller;
import jakarta.servlet.http.HttpServletRequest;
import org.jeecg.common.api.vo.Result;

View File

@@ -1,4 +1,4 @@
package org.jeecg.modules.xslmes.mcs.controller;
package org.jeecg.modules.xslmes.mcs.controller;
import jakarta.servlet.http.HttpServletRequest;
import org.jeecg.common.api.vo.Result;

View File

@@ -1,4 +1,4 @@
package org.jeecg.modules.xslmes.mcs.controller;
package org.jeecg.modules.xslmes.mcs.controller;
import jakarta.servlet.http.HttpServletRequest;
import org.jeecg.common.api.vo.Result;

View File

@@ -1,4 +1,4 @@
package org.jeecg.modules.xslmes.mcs.controller;
package org.jeecg.modules.xslmes.mcs.controller;
import jakarta.servlet.http.HttpServletRequest;
import org.jeecg.common.api.vo.Result;

View File

@@ -1,4 +1,4 @@
package org.jeecg.modules.xslmes.mcs.controller;
package org.jeecg.modules.xslmes.mcs.controller;
import jakarta.servlet.http.HttpServletRequest;
import org.jeecg.common.api.vo.Result;

View File

@@ -1,4 +1,4 @@
package org.jeecg.modules.xslmes.mcs.controller;
package org.jeecg.modules.xslmes.mcs.controller;
import jakarta.servlet.http.HttpServletRequest;
import org.jeecg.common.api.vo.Result;

View File

@@ -1,4 +1,4 @@
package org.jeecg.modules.xslmes.mcs.controller;
package org.jeecg.modules.xslmes.mcs.controller;
import jakarta.servlet.http.HttpServletRequest;
import org.jeecg.common.api.vo.Result;

View File

@@ -1,4 +1,4 @@
package org.jeecg.modules.xslmes.mcs.controller;
package org.jeecg.modules.xslmes.mcs.controller;
import jakarta.servlet.http.HttpServletRequest;
import org.jeecg.common.api.vo.Result;

View File

@@ -1,4 +1,4 @@
package org.jeecg.modules.xslmes.mcs.controller;
package org.jeecg.modules.xslmes.mcs.controller;
import jakarta.servlet.http.HttpServletRequest;
import org.jeecg.common.api.vo.Result;

View File

@@ -1,4 +1,4 @@
package org.jeecg.modules.xslmes.mcs.controller;
package org.jeecg.modules.xslmes.mcs.controller;
import jakarta.servlet.http.HttpServletRequest;
import org.jeecg.common.api.vo.Result;

View File

@@ -1,4 +1,4 @@
package org.jeecg.modules.xslmes.mcs.controller;
package org.jeecg.modules.xslmes.mcs.controller;
import jakarta.servlet.http.HttpServletRequest;
import org.jeecg.common.api.vo.Result;

View File

@@ -66,6 +66,12 @@ public class SysThirdAppConfig {
@Schema(description = "是否启用(0-否,1-是)")
private Integer status;
//update-begin---author:GHT ---date:20260604 for【钉钉Stream回调】Stream事件推送主配置标识-----
/**Stream事件推送主配置(0-否,1-是)*/
@Schema(description = "Stream事件推送主配置(0-否,1-是),同一 thirdType 中只有一条记录为1")
private Integer streamEnabled;
//update-end---author:GHT ---date:20260604 for【钉钉Stream回调】Stream事件推送主配置标识-----
/**创建日期*/
@Excel(name = "创建日期", width = 20, format = "yyyy-MM-dd HH:mm:ss")
@JsonFormat(timezone = "GMT+8",pattern = "yyyy-MM-dd HH:mm:ss")

View File

@@ -1196,6 +1196,70 @@ public class ThirdAppDingtalkServiceImpl implements IThirdAppService {
return configMapper.getThirdConfigByThirdType(tenantId,MessageTypeEnum.DD.getType());
}
//update-begin---author:GHT ---date:20260604 for【钉钉Stream回调】获取钉钉应用凭证Stream模式专用-----
/**
* 获取钉钉应用凭证 [clientId, clientSecret],供 Stream 长连接使用。
* <p>
* 优先级:
* ① stream_enabled=1 的记录(管理员在「钉钉集成」页面显式指定的 Stream 主配置)
* ② 兜底AppKey 长度 > 10 的第一条有效记录(迁移期或未配置时保持可用)
*
* @return [appKey, appSecret];未配置时返回 null
*/
public String[] getDingAppCredentials() {
java.util.List<SysThirdAppConfig> all = configMapper.selectList(
new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<SysThirdAppConfig>()
.eq(SysThirdAppConfig::getThirdType, THIRD_TYPE)
.isNotNull(SysThirdAppConfig::getClientId)
.isNotNull(SysThirdAppConfig::getClientSecret)
// stream_enabled=1 的排最前面
.orderByDesc(SysThirdAppConfig::getStreamEnabled)
.orderByDesc(SysThirdAppConfig::getTenantId));
for (SysThirdAppConfig c : all) {
String appKey = c.getClientId();
String appSecret = c.getClientSecret();
if (oConvertUtils.isNotEmpty(appKey) && appKey.length() > 10
&& oConvertUtils.isNotEmpty(appSecret) && appSecret.length() > 10) {
return new String[]{appKey, appSecret};
}
}
return null;
}
//update-end---author:GHT ---date:20260604 for【钉钉Stream回调】获取钉钉应用凭证Stream模式专用-----
//update-begin---author:GHT ---date:20260604 for【钉钉Stream回调】后台线程专用AccessToken绕过租户检查-----
/**
* 后台线程专用:获取钉钉 AccessToken不依赖 TenantContext。
* <p>
* 原 {@link #getAccessToken()} 内部调 {@code tenantIzExist(0)}
* 后台线程无租户上下文时 tenantId 默认 0若 tenant=0 不存在直接抛异常。
* 本方法复用 {@link #getDingAppCredentials()} 的安全查询,绕过租户校验。
*
* @return AccessToken 字符串;未配置或失败时返回 null
*/
public String getAccessTokenForBackground() {
String[] creds = getDingAppCredentials();
if (creds == null || oConvertUtils.isEmpty(creds[0]) || oConvertUtils.isEmpty(creds[1])) {
log.warn("[DingBg] 未找到有效钉钉配置,无法获取 AccessToken");
return null;
}
try {
// JdtBaseAPI 已在文件顶部 import com.jeecg.dingtalk.api.base.JdtBaseAPI
// AccessToken 已在文件顶部 import com.jeecg.dingtalk.api.core.vo.AccessToken
AccessToken token = JdtBaseAPI.getAccessToken(creds[0], creds[1]);
if (token != null && oConvertUtils.isNotEmpty(token.getAccessToken())) {
return token.getAccessToken();
}
log.warn("[DingBg] getAccessToken 返回空");
return null;
} catch (Exception e) {
log.warn("[DingBg] 获取 AccessToken 失败: {}", e.getMessage());
return null;
}
}
//update-end---author:GHT ---date:20260604 for【钉钉Stream回调】后台线程专用AccessToken绕过租户检查-----
/**
* 获取钉钉accessToken
* @param config

View File

@@ -126,6 +126,8 @@ spring:
web-stat-filter:
enabled: true
dynamic:
# 非主数据源懒加载,避免花生壳/SQL Server 未就绪时拖慢或阻断启动
lazy: true
druid:
# 连接池的配置信息
# 初始化大小,最小,最大
@@ -165,9 +167,18 @@ spring:
#update-begin---author:geh ---date:2026-06-02 for【MES上辅机】新增 SQL Server 中间表数据源MES_ShareDB-----------
sqlserver_mcs:
driver-class-name: com.microsoft.sqlserver.jdbc.SQLServerDriver
url: jdbc:sqlserver://1lo04860wn636.vicp.fun:31601;DatabaseName=MES_ShareDB;encrypt=false;trustServerCertificate=true;SelectMethod=cursor;
# loginTimeout/connectTimeout花生壳映射不稳定时延长 TCP/登录等待(单位:秒 / 毫秒)
url: jdbc:sqlserver://1lo04860wn636.vicp.fun:31601;DatabaseName=MES_ShareDB;encrypt=false;trustServerCertificate=true;SelectMethod=cursor;loginTimeout=120;connectTimeout=120000;
username: sa
password: 123456
druid:
initial-size: 0
min-idle: 0
max-wait: 120000
connect-timeout: 120000
connection-error-retry-attempts: 10
time-between-connect-error-millis: 3000
break-after-acquire-failure: false
#update-end---author:geh ---date:2026-06-02 for【MES上辅机】新增 SQL Server 中间表数据源MES_ShareDB-----------
# # shardingjdbc数据源
# sharding-db:

View File

@@ -0,0 +1,5 @@
-- 20260604钉钉回调幂等去重台账新增 processed_op_count 字段
-- 用于 bpms_task_change 节点回调的 DB 乐观锁去重记录已处理的节点回调数
-- 默认 0每处理一个节点后 +1并发事件通过 WHERE processed_op_count < ? 条件竞争
ALTER TABLE mes_xsl_approval_record
ADD COLUMN processed_op_count INT NOT NULL DEFAULT 0 COMMENT '钉钉回调已处理节点数幂等去重';

View File

@@ -0,0 +1,43 @@
-- ============================================================
-- MESToDing审批配置 - 审批模板绑定
-- author: GHT date: 2026-06-04
-- ============================================================
CREATE TABLE IF NOT EXISTS `mes_xsl_ding_tpl_bind` (
`id` varchar(32) NOT NULL COMMENT '主键',
`biz_code` varchar(64) NOT NULL COMMENT '业务编码(sys_permission.id)',
`biz_name` varchar(200) DEFAULT NULL COMMENT '业务名称(菜单名)',
`template_id` varchar(32) NOT NULL COMMENT '钉钉审批模板ID',
`template_name` varchar(200) DEFAULT NULL COMMENT '钉钉审批模板名称',
`field_mapping_json` longtext DEFAULT NULL COMMENT '字段绑定JSON:[{componentId,componentLabel,componentName,parentId,bizField}]',
`create_by` varchar(50) DEFAULT NULL COMMENT '创建人',
`create_time` datetime DEFAULT NULL COMMENT '创建时间',
`update_by` varchar(50) DEFAULT NULL COMMENT '更新人',
`update_time` datetime DEFAULT NULL COMMENT '更新时间',
`del_flag` int NOT NULL DEFAULT 0 COMMENT '逻辑删除:0正常 1已删除',
`tenant_id` int NOT NULL DEFAULT 0 COMMENT '租户ID',
`sys_org_code` varchar(64) DEFAULT NULL COMMENT '所属部门编码',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_ding_tpl_bind_biz_code` (`biz_code`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='MES-钉钉审批模板绑定';
-- ========== 菜单权限 ==========
-- 子菜单审批模板绑定挂在 MESToDing审批配置 父菜单 178046026420801
INSERT INTO sys_permission(id, parent_id, name, url, component, component_name, redirect, menu_type, perms, perms_type, sort_no, always_show, icon, is_route, is_leaf, keep_alive, hidden, hide_tab, description, status, del_flag, rule_flag, create_by, create_time, update_by, update_time, internal_or_external)
VALUES ('178046026420810', '178046026420801', '审批模板绑定', '/xslmes/dingTplBindList', 'xslmes/dingtalk/dingTplBind/index', NULL, NULL, 0, NULL, '1', 2.00, 0, 'ant-design:link-outlined', 1, 0, 0, 0, 0, NULL, '1', 0, 0, 'admin', '2026-06-04 00:00:00', NULL, NULL, 0);
-- 按钮权限
INSERT INTO sys_permission(id, parent_id, name, url, component, is_route, component_name, redirect, menu_type, perms, perms_type, sort_no, always_show, icon, is_leaf, keep_alive, hidden, hide_tab, description, create_by, create_time, update_by, update_time, del_flag, rule_flag, status, internal_or_external)
VALUES ('178046026420811', '178046026420810', '查询审批模板绑定', NULL, NULL, 0, NULL, NULL, 2, 'xslmes:mesXslDingTplBind:list', '1', NULL, 0, NULL, 1, 0, 0, 0, NULL, 'admin', '2026-06-04 00:00:00', NULL, NULL, 0, 0, '1', 0);
INSERT INTO sys_permission(id, parent_id, name, url, component, is_route, component_name, redirect, menu_type, perms, perms_type, sort_no, always_show, icon, is_leaf, keep_alive, hidden, hide_tab, description, create_by, create_time, update_by, update_time, del_flag, rule_flag, status, internal_or_external)
VALUES ('178046026420812', '178046026420810', '保存审批模板绑定', NULL, NULL, 0, NULL, NULL, 2, 'xslmes:mesXslDingTplBind:save', '1', NULL, 0, NULL, 1, 0, 0, 0, NULL, 'admin', '2026-06-04 00:00:00', NULL, NULL, 0, 0, '1', 0);
INSERT INTO sys_permission(id, parent_id, name, url, component, is_route, component_name, redirect, menu_type, perms, perms_type, sort_no, always_show, icon, is_leaf, keep_alive, hidden, hide_tab, description, create_by, create_time, update_by, update_time, del_flag, rule_flag, status, internal_or_external)
VALUES ('178046026420813', '178046026420810', '删除审批模板绑定', NULL, NULL, 0, NULL, NULL, 2, 'xslmes:mesXslDingTplBind:delete', '1', NULL, 0, NULL, 1, 0, 0, 0, NULL, 'admin', '2026-06-04 00:00:00', NULL, NULL, 0, 0, '1', 0);
-- ========== admin 角色授权 ==========
INSERT INTO sys_role_permission (id, role_id, permission_id, data_rule_ids, operate_date, operate_ip) VALUES (REPLACE(UUID(),'-',''), 'f6817f48af4fb3af11b9e8bf182f618b', '178046026420810', NULL, '2026-06-04 00:00:00', '127.0.0.1');
INSERT INTO sys_role_permission (id, role_id, permission_id, data_rule_ids, operate_date, operate_ip) VALUES (REPLACE(UUID(),'-',''), 'f6817f48af4fb3af11b9e8bf182f618b', '178046026420811', NULL, '2026-06-04 00:00:00', '127.0.0.1');
INSERT INTO sys_role_permission (id, role_id, permission_id, data_rule_ids, operate_date, operate_ip) VALUES (REPLACE(UUID(),'-',''), 'f6817f48af4fb3af11b9e8bf182f618b', '178046026420812', NULL, '2026-06-04 00:00:00', '127.0.0.1');
INSERT INTO sys_role_permission (id, role_id, permission_id, data_rule_ids, operate_date, operate_ip) VALUES (REPLACE(UUID(),'-',''), 'f6817f48af4fb3af11b9e8bf182f618b', '178046026420813', NULL, '2026-06-04 00:00:00', '127.0.0.1');

View File

@@ -0,0 +1,56 @@
-- QH-MES审批台账 MES/钉钉 统一审批门禁台账
SET NAMES utf8mb4;
CREATE TABLE IF NOT EXISTS `mes_xsl_approval_record` (
`id` varchar(32) NOT NULL COMMENT '主键',
`biz_table` varchar(100) NOT NULL COMMENT '业务单据表名',
`biz_table_name` varchar(200) DEFAULT NULL COMMENT '业务单据中文名',
`biz_code` varchar(64) DEFAULT NULL COMMENT '业务编码(菜单permission.id钉钉绑定用)',
`biz_data_id` varchar(64) NOT NULL COMMENT '业务单据记录ID',
`biz_title` varchar(300) DEFAULT NULL COMMENT '业务单据展示标题',
`channel` varchar(20) NOT NULL COMMENT '审批通道 MES/DINGTALK',
`external_instance_id` varchar(128) DEFAULT NULL COMMENT '外部实例ID(MES实例ID或钉钉instanceId)',
`flow_id` varchar(32) DEFAULT NULL COMMENT 'MES审批流ID',
`flow_name` varchar(100) DEFAULT NULL COMMENT 'MES审批流名称',
`template_id` varchar(32) DEFAULT NULL COMMENT '钉钉审批模板ID',
`template_name` varchar(200) DEFAULT NULL COMMENT '钉钉审批模板名称',
`launch_no` int DEFAULT '1' COMMENT '同一业务单据第几次发起',
`status` varchar(2) DEFAULT '0' COMMENT '状态 0流转中 1通过 2拒绝 3撤销 4发起失败',
`apply_user` varchar(50) DEFAULT NULL COMMENT '发起人username',
`apply_user_name` varchar(100) DEFAULT NULL COMMENT '发起人姓名',
`apply_time` datetime DEFAULT NULL COMMENT '发起时间',
`finish_time` datetime DEFAULT NULL COMMENT '办结时间',
`remark` varchar(500) DEFAULT NULL COMMENT '备注/驳回理由等',
`del_flag` int DEFAULT '0' COMMENT '逻辑删除 0正常 1已删除',
`tenant_id` int DEFAULT NULL COMMENT '租户ID',
`sys_org_code` varchar(64) DEFAULT NULL COMMENT '所属部门编码',
`create_by` varchar(50) DEFAULT NULL COMMENT '创建人',
`create_time` datetime DEFAULT NULL COMMENT '创建时间',
`update_by` varchar(50) DEFAULT NULL COMMENT '更新人',
`update_time` datetime DEFAULT NULL COMMENT '更新时间',
PRIMARY KEY (`id`),
KEY `idx_appr_rec_biz` (`tenant_id`, `biz_table`, `biz_data_id`, `apply_time`),
KEY `idx_appr_rec_ext` (`channel`, `external_instance_id`),
KEY `idx_appr_rec_status` (`tenant_id`, `biz_table`, `biz_data_id`, `status`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='MES审批台账(跨通道门禁)';
-- 审批通道字典
INSERT IGNORE INTO `sys_dict` (`id`, `dict_name`, `dict_code`, `description`, `del_flag`, `create_by`, `create_time`, `type`, `tenant_id`)
VALUES ('1995000000000000350', '审批通道', 'mes_xsl_approval_channel', 'MES/钉钉审批通道', 0, 'admin', NOW(), 0, 0);
INSERT IGNORE INTO `sys_dict_item` (`id`, `dict_id`, `item_text`, `item_value`, `description`, `sort_order`, `status`, `create_by`, `create_time`)
VALUES
('1995000000000000351', '1995000000000000350', 'MES审批', 'MES', 'MES审批', 1, 1, 'admin', NOW()),
('1995000000000000352', '1995000000000000350', '钉钉审批', 'DINGTALK', '钉钉审批', 2, 1, 'admin', NOW());
-- 台账状态字典(在实例状态基础上增加发起失败)
INSERT IGNORE INTO `sys_dict` (`id`, `dict_name`, `dict_code`, `description`, `del_flag`, `create_by`, `create_time`, `type`, `tenant_id`)
VALUES ('1995000000000000353', '审批台账状态', 'mes_xsl_approval_record_status', '审批台账状态', 0, 'admin', NOW(), 0, 0);
INSERT IGNORE INTO `sys_dict_item` (`id`, `dict_id`, `item_text`, `item_value`, `description`, `sort_order`, `status`, `create_by`, `create_time`)
VALUES
('1995000000000000354', '1995000000000000353', '流转中', '0', '流转中', 1, 1, 'admin', NOW()),
('1995000000000000355', '1995000000000000353', '审批通过', '1', '审批通过', 2, 1, 'admin', NOW()),
('1995000000000000356', '1995000000000000353', '审批拒绝', '2', '审批拒绝', 3, 1, 'admin', NOW()),
('1995000000000000357', '1995000000000000353', '已撤销', '3', '已撤销', 4, 1, 'admin', NOW()),
('1995000000000000358', '1995000000000000353', '发起失败', '4', '发起失败', 5, 1, 'admin', NOW());

View File

@@ -0,0 +1,18 @@
-- ============================================================
-- MESToDing审批配置 - 审批台账菜单
-- author: GHT date: 2026-06-04
-- ============================================================
-- 子菜单审批台账挂在 MESToDing审批配置 父菜单 178046026420801
INSERT INTO sys_permission(id, parent_id, name, url, component, component_name, redirect, menu_type, perms, perms_type, sort_no, always_show, icon, is_route, is_leaf, keep_alive, hidden, hide_tab, description, status, del_flag, rule_flag, create_by, create_time, update_by, update_time, internal_or_external)
VALUES ('178046026420820', '178046026420801', '审批台账', '/xslmes/mesXslApprovalRecordList', 'xslmes/approval/mesXslApprovalRecord/MesXslApprovalRecordList', NULL, NULL, 0, NULL, '1', 3.00, 0, 'ant-design:audit-outlined', 1, 0, 0, 0, 0, NULL, '1', 0, 0, 'admin', '2026-06-04 00:00:00', NULL, NULL, 0);
INSERT INTO sys_permission(id, parent_id, name, url, component, is_route, component_name, redirect, menu_type, perms, perms_type, sort_no, always_show, icon, is_leaf, keep_alive, hidden, hide_tab, description, create_by, create_time, update_by, update_time, del_flag, rule_flag, status, internal_or_external)
VALUES ('178046026420821', '178046026420820', '查询审批台账', NULL, NULL, 0, NULL, NULL, 2, 'xslmes:mes_xsl_approval_record:list', '1', NULL, 0, NULL, 1, 0, 0, 0, NULL, 'admin', '2026-06-04 00:00:00', NULL, NULL, 0, 0, '1', 0);
INSERT INTO sys_permission(id, parent_id, name, url, component, is_route, component_name, redirect, menu_type, perms, perms_type, sort_no, always_show, icon, is_leaf, keep_alive, hidden, hide_tab, description, create_by, create_time, update_by, update_time, del_flag, rule_flag, status, internal_or_external)
VALUES ('178046026420822', '178046026420820', '导出审批台账', NULL, NULL, 0, NULL, NULL, 2, 'xslmes:mes_xsl_approval_record:exportXls', '1', NULL, 0, NULL, 1, 0, 0, 0, NULL, 'admin', '2026-06-04 00:00:00', NULL, NULL, 0, 0, '1', 0);
INSERT INTO sys_role_permission (id, role_id, permission_id, data_rule_ids, operate_date, operate_ip) VALUES (REPLACE(UUID(),'-',''), 'f6817f48af4fb3af11b9e8bf182f618b', '178046026420820', NULL, '2026-06-04 00:00:00', '127.0.0.1');
INSERT INTO sys_role_permission (id, role_id, permission_id, data_rule_ids, operate_date, operate_ip) VALUES (REPLACE(UUID(),'-',''), 'f6817f48af4fb3af11b9e8bf182f618b', '178046026420821', NULL, '2026-06-04 00:00:00', '127.0.0.1');
INSERT INTO sys_role_permission (id, role_id, permission_id, data_rule_ids, operate_date, operate_ip) VALUES (REPLACE(UUID(),'-',''), 'f6817f48af4fb3af11b9e8bf182f618b', '178046026420822', NULL, '2026-06-04 00:00:00', '127.0.0.1');

View File

@@ -0,0 +1,4 @@
-- GHT 20260604 钉钉Stream回调sys_third_app_config 新增 stream_enabled 字段
-- 用于在第三方应用配置页面指定哪条钉钉配置作为 Stream 事件推送的主连接
ALTER TABLE sys_third_app_config
ADD COLUMN stream_enabled TINYINT(1) NOT NULL DEFAULT 0 COMMENT 'Stream事件推送主配置(0-,1-)';

View File

@@ -0,0 +1,5 @@
-- GHT 20260604 钉钉Stream回调mes_xsl_approval_record 新增 origin_status/status_field 字段
-- mes_xsl_approval_instance 对齐用于驳回时共用 isBizAtOriginStatus 逻辑
ALTER TABLE mes_xsl_approval_record
ADD COLUMN status_field VARCHAR(64) NULL COMMENT '业务状态字段名发起时快照',
ADD COLUMN origin_status VARCHAR(64) NULL COMMENT '发起审批时业务状态原值驳回回写依据';

View File

@@ -0,0 +1,4 @@
-- process_code 允许草稿阶段为空创建钉钉模板前默认空串
-- author: GHT date: 2026-06-04
ALTER TABLE `mes_xsl_ding_process_tpl`
MODIFY COLUMN `process_code` varchar(100) NOT NULL DEFAULT '' COMMENT '钉钉processCode未推送钉钉前为空';

View File

@@ -0,0 +1,807 @@
<template>
<a-modal
v-model:open="visible"
:title="`发起钉钉审批 · ${tplData?.templateName || ''}`"
:width="multiRow ? 1120 : 940"
:confirm-loading="submitting"
ok-text="发起审批"
cancel-text="取消"
destroy-on-close
:body-style="{ padding: 0 }"
@ok="handleSubmit"
@cancel="visible = false"
>
<div class="dal-body">
<!-- 左侧审批流时间轴 -->
<div class="dal-timeline-panel">
<div class="dal-panel-title">审批流程</div>
<div v-if="!selectedFlowId" class="dal-timeline-empty">
<div class="dal-timeline-empty-icon">🔗</div>
<div>请在右侧审批流配置<br>页签中选择审批流</div>
</div>
<a-spin v-else-if="previewLoading" style="display:flex;justify-content:center;padding:32px 0" />
<div v-else class="dal-timeline">
<div class="dal-ts-step">
<div class="dal-ts-left">
<div class="dal-ts-dot dal-ts-dot--start"></div>
<div class="dal-ts-line" v-if="approverPreview.length > 0"></div>
</div>
<div class="dal-ts-content">
<div class="dal-ts-name">发起人</div>
<div class="dal-ts-sub">所有人可发起</div>
</div>
</div>
<div v-for="(node, ni) in approverPreview" :key="node.nodeId || ni" class="dal-ts-step">
<div class="dal-ts-left">
<div class="dal-ts-dot"
:class="[node.nodeType==='cc'?'dal-ts-dot--cc':'dal-ts-dot--approver', !node.allResolved?'dal-ts-dot--warn':'']">
</div>
<div class="dal-ts-line" v-if="ni < approverPreview.length - 1"></div>
</div>
<div class="dal-ts-content">
<div class="dal-ts-tags">
<span class="dal-ts-badge" :class="node.nodeType==='cc'?'dal-ts-badge--cc':'dal-ts-badge--approver'">
{{ node.nodeType==='cc'?'抄送':'审批' }}
</span>
<span v-if="node.nodeType!=='cc'" class="dal-ts-mode">{{ modeLabel(node.multiMode) }}</span>
</div>
<div class="dal-ts-name">{{ node.nodeName }}</div>
<div class="dal-ts-users">
<template v-for="(u, ui) in node.users" :key="u.username">
<span :class="u.resolved?'dal-ts-user--ok':'dal-ts-user--err'">{{ u.realname }}</span>
<span v-if="ui < node.users.length-1" style="color:#ccc;margin:0 2px">·</span>
</template>
</div>
<div v-if="!node.allResolved" class="dal-ts-unresolved"> 有未解析成员请补充手机号</div>
</div>
</div>
<div class="dal-ts-step" v-if="approverPreview.length > 0">
<div class="dal-ts-left"><div class="dal-ts-dot dal-ts-dot--end"></div></div>
<div class="dal-ts-content"><div class="dal-ts-name" style="color:#888">结束</div></div>
</div>
</div>
</div>
<div class="dal-panel-divider"></div>
<!-- 中间主内容表单字段 + 审批流配置 -->
<div class="dal-content-panel">
<a-tabs v-model:activeKey="activeTab" size="small" class="dal-tabs">
<!-- 表单展示只读 -->
<a-tab-pane key="form" tab="表单字段">
<div class="dal-form-scroll">
<a-spin :spinning="loading" tip="加载表单字段中...">
<a-alert v-if="loadError" type="error" :message="loadError" show-icon style="margin-bottom:12px" />
<template v-else-if="!loading">
<a-alert
type="info"
show-icon
style="margin-bottom:12px"
message="字段已根据绑定配置从业务单据自动填充,不可手动修改。如需调整请先更新审批模板绑定中的字段映射。"
/>
<div v-if="dingFields.length === 0" class="dal-form-empty">该模板暂无表单字段</div>
<template v-for="field in dingFields" :key="field.label">
<div v-if="field.componentName === 'TextNote'" class="dal-form-note">{{ field.label }}</div>
<!-- 明细表只读展示 -->
<template v-else-if="field.componentName === 'TableField'">
<div class="dal-form-item">
<div class="dal-field-label" :class="{'dal-field-label--required': field.required}">{{ field.label }}</div>
<div class="dal-table-wrap">
<table class="dal-table">
<thead>
<tr>
<th style="width:40px;text-align:center">#</th>
<th v-for="child in field.children||[]" :key="child.label">{{ child.label }}</th>
</tr>
</thead>
<tbody>
<tr v-for="(row, rowIdx) in getTableRows(field.label)" :key="rowIdx">
<td style="text-align:center;color:#aaa">{{ rowIdx+1 }}</td>
<td v-for="child in field.children||[]" :key="child.label">
<span class="dal-readonly-cell">{{ row[child.label] ?? '—' }}</span>
</td>
</tr>
<tr v-if="getTableRows(field.label).length === 0">
<td :colspan="(field.children?.length||0)+1" style="text-align:center;color:#bbb;padding:10px 0">暂无数据</td>
</tr>
</tbody>
</table>
</div>
</div>
</template>
<!-- 普通字段只读 -->
<div v-else class="dal-form-item">
<div class="dal-field-label" :class="{'dal-field-label--required': field.required}">{{ field.label }}</div>
<a-input
:value="displayValue(field)"
disabled
class="dal-readonly-input"
/>
</div>
</template>
</template>
</a-spin>
</div>
</a-tab-pane>
<!-- 审批流配置只读查看 -->
<a-tab-pane key="flow">
<template #tab>
审批流配置
<a-badge v-if="hasUnresolved" color="red" style="margin-left:4px" />
</template>
<div class="dal-form-scroll">
<a-alert
type="info"
show-icon
style="margin-bottom:14px"
>
<template #message>
审批流程仅供查看不可在此修改如需调整请前往
<a class="flow-readonly-link" @click="goToTplConfig">钉钉审批模板配置</a>
中更改绑定的审批流
</template>
</a-alert>
<div class="flow-select-row">
<a-select
v-model:value="selectedFlowId"
style="flex:1;min-width:0"
placeholder="(未绑定审批流)"
:loading="flowLoading"
:options="flowSelectOptions"
disabled
>
<template #option="{ label, status, remark }">
<div class="flow-opt-item">
<span class="flow-opt-name">{{ label }}</span>
<span style="display:flex;align-items:center;gap:6px;flex-shrink:0">
<span v-if="remark" class="flow-opt-remark">{{ remark }}</span>
<a-tag :color="status==='1'?'green':status==='2'?'default':'orange'"
style="margin:0;font-size:11px;line-height:16px;padding:0 5px">
{{ status==='1'?'已发布':status==='2'?'已停用':'草稿' }}
</a-tag>
</span>
</div>
</template>
</a-select>
</div>
<template v-if="selectedFlowId">
<a-divider style="margin:14px 0 10px" />
<div class="preview-title">
审批节点 · 人员解析
<a-spin :spinning="previewLoading" size="small" style="margin-left:8px" />
</div>
<div v-if="!previewLoading && approverPreview.length === 0" style="color:#bbb;font-size:12px;margin-top:6px">
该审批流暂无审批人节点
</div>
<div v-for="(node, ni) in approverPreview" :key="node.nodeId||ni" class="preview-node"
:class="{'preview-node--cc': node.nodeType==='cc'}">
<div class="preview-node-hd">
<a-tag :color="node.nodeType==='cc'?'blue':'orange'" style="margin:0 6px 0 0;font-size:11px">
{{ node.nodeType==='cc'?'抄送':'审批' }}
</a-tag>
<span class="preview-node-name">{{ node.nodeName }}</span>
<span class="preview-node-mode">{{ node.nodeType==='cc'?'位置自动判断':modeLabel(node.multiMode) }}</span>
</div>
<div v-for="u in node.users" :key="u.username" class="preview-user">
<a-tag v-if="u.resolved" color="success" style="margin:0">{{ u.realname }}{{ u.username }}</a-tag>
<a-tag v-else-if="u.unsupported" color="default" style="margin:0">{{ u.realname }}不支持自动解析</a-tag>
<a-tag v-else color="error" style="margin:0">{{ u.realname }}{{ u.username }}未找到钉钉账号</a-tag>
</div>
<div v-if="!node.allResolved" class="preview-supplement">
<a-input
v-model:value="supplementPhones[node.nodeId||String(ni)]"
:placeholder="node.nodeType==='cc'?'补充抄送人手机号,多个用逗号分隔':'补充审批人手机号,多个用逗号分隔'"
allow-clear size="small"
/>
<div class="dal-field-hint" style="margin-top:3px">手机号需在企业钉钉注册与自动解析的成员合并</div>
</div>
</div>
<a-alert v-if="hasUnresolved" type="warning" show-icon style="margin-top:10px;font-size:12px"
message="部分节点有未解析成员,请补充手机号后再发起审批" />
</template>
</div>
</a-tab-pane>
</a-tabs>
</div>
<!-- 右侧已选数据列表多条时显示 -->
<template v-if="multiRow">
<div class="dal-panel-divider"></div>
<div class="dal-rows-panel">
<div class="dal-panel-title">
已选数据
<span class="dal-rows-badge">{{ allRows.length }}</span>
</div>
<div class="dal-rows-list">
<div
v-for="(row, idx) in allRows"
:key="idx"
class="dal-row-item"
:class="{ 'dal-row-item--active': idx === currentRowIndex }"
@click="switchRow(idx)"
>
<div class="dal-row-index">{{ idx + 1 }}</div>
<div class="dal-row-label">{{ getRowLabel(row, idx) }}</div>
</div>
</div>
</div>
</template>
</div>
</a-modal>
</template>
<script lang="ts" setup>
import { ref, reactive, computed } from 'vue';
import { useRouter } from 'vue-router';
import { useMessage } from '/@/hooks/web/useMessage';
import {
getTemplateDetail,
queryById as queryTplById,
launchApproval,
getApprovalFlowList,
previewFlowApprovers,
} from '/@/views/xslmes/dingtalk/mesXslDingProcessTpl/MesXslDingProcessTpl.api';
import { checkCanLaunch } from '/@/views/approval/gate/approvalGate.api';
const emit = defineEmits(['success']);
const { createMessage } = useMessage();
const router = useRouter();
const visible = ref(false);
const loading = ref(false);
const submitting = ref(false);
const loadError = ref('');
const activeTab = ref('form');
interface BindInfo { id: string; templateId: string; templateName: string; fieldMappingJson?: string; bizCode?: string }
const tplData = ref<BindInfo | null>(null);
// 多条数据支持
const allRows = ref<any[]>([]);
const currentRowIndex = ref(0);
const multiRow = computed(() => allRows.value.length > 0);
const dingFields = ref<any[]>([]);
const formValues = reactive<Record<string, any>>({});
const tableValues = reactive<Record<string, Record<string, string>[]>>({});
const flowLoading = ref(false);
const flowList = ref<any[]>([]);
const selectedFlowId = ref('');
const previewLoading = ref(false);
const approverPreview = ref<any[]>([]);
const supplementPhones = reactive<Record<string, string>>({});
const hasUnresolved = computed(() => approverPreview.value.some(n => !n.allResolved));
const flowSelectOptions = computed(() =>
flowList.value.map(f => ({
value: f.id, label: f.flowName || f.name,
status: f.status, remark: f.remark || '',
}))
);
function modeLabel(mode: string) {
if (mode === 'none') return '单人';
if (mode === 'or') return '或签';
if (mode === 'sequence') return '依次';
return '会签';
}
// ══ 打开弹窗,支持单条或多条 ══
async function open(bindInfo: BindInfo, rowsOrRow: any[] | any) {
tplData.value = bindInfo;
allRows.value = Array.isArray(rowsOrRow) ? rowsOrRow : [rowsOrRow];
currentRowIndex.value = 0;
resetFieldValues();
loadError.value = '';
activeTab.value = 'form';
selectedFlowId.value = '';
approverPreview.value = [];
visible.value = true;
loading.value = true;
try {
const [detail, flows, tplRecord] = await Promise.all([
getTemplateDetail(bindInfo.templateId),
getApprovalFlowList({ pageSize: 200 }),
queryTplById(bindInfo.templateId),
]);
dingFields.value = detail?.dingFields || [];
if (detail?.schemaError) loadError.value = detail.schemaError;
flowList.value = flows?.records || flows || [];
for (const f of dingFields.value) {
if (f.componentName === 'TableField') tableValues[f.label] = [];
}
applyPrefillForRow(currentRowIndex.value);
const presetFlowId = tplRecord?.flowId;
if (presetFlowId) {
selectedFlowId.value = presetFlowId;
loadPreview(presetFlowId);
}
} catch (e: any) {
loadError.value = e?.message || '加载模板字段失败';
} finally {
loading.value = false;
}
}
function goToTplConfig() {
visible.value = false;
router.push('/xslmes/mesXslDingProcessTplList');
}
defineExpose({ open });
// ══ 切换数据行 ══
function switchRow(idx: number) {
if (idx === currentRowIndex.value) return;
currentRowIndex.value = idx;
resetFieldValues();
applyPrefillForRow(idx);
activeTab.value = 'form';
}
function resetFieldValues() {
Object.keys(formValues).forEach(k => delete formValues[k]);
Object.keys(tableValues).forEach(k => delete tableValues[k]);
Object.keys(supplementPhones).forEach(k => delete supplementPhones[k]);
for (const f of dingFields.value) {
if (f.componentName === 'TableField') tableValues[f.label] = [];
}
}
function applyPrefillForRow(idx: number) {
const rowData = allRows.value[idx];
if (rowData && tplData.value?.fieldMappingJson) {
applyPrefill(dingFields.value, tplData.value.fieldMappingJson, rowData);
}
}
// ══ 行标签(用于右侧列表显示) ══
const LABEL_SKIP = new Set([
'id', 'createBy', 'createTime', 'updateBy', 'updateTime',
'delFlag', 'sysOrgCode', 'tenantId', 'version',
]);
function getRowLabel(row: any, index: number): string {
for (const [key, val] of Object.entries(row ?? {})) {
if (LABEL_SKIP.has(key)) continue;
if (val && typeof val === 'string' && val.length <= 40) return val;
if (val !== null && val !== undefined && typeof val === 'number') return String(val);
}
return `条目 ${index + 1}`;
}
// ══ 预填充 ══
interface MappingItem {
componentId: string;
componentLabel: string;
componentName: string;
parentId?: string;
bizField?: string;
}
function applyPrefill(fields: any[], mappingJson: string, rowData: any) {
let mapping: MappingItem[] = [];
try { mapping = JSON.parse(mappingJson); } catch { return; }
const byId = new Map(mapping.map(m => [m.componentId, m]));
for (const field of fields) {
if (field.componentName === 'TextNote') continue;
const cid = field.id || field.label;
const m = byId.get(cid);
if (field.componentName === 'TableField') {
const slotName = m?.bizField;
if (!slotName) continue;
const arr: any[] = getNestedValue(rowData, slotName);
if (!Array.isArray(arr) || !arr.length) continue;
const childMappings = mapping.filter(x => x.parentId === cid && x.bizField);
tableValues[field.label] = arr.map(element => {
const row: Record<string, string> = {};
for (const child of childMappings) {
const parts = (child.bizField || '').split('.');
const colKey = parts.slice(1).join('.');
const val = colKey ? getNestedValue(element, colKey) : undefined;
row[child.componentLabel] = val !== undefined && val !== null ? String(val) : '';
}
for (const child of (field.children || [])) {
if (!(child.label in row)) row[child.label] = '';
}
return row;
});
} else {
if (!m?.bizField) continue;
const val = getNestedValue(rowData, m.bizField);
if (val === undefined || val === null) continue;
formValues[field.label] = formatForDisplay(val, field.componentName);
}
}
}
function getNestedValue(obj: any, path: string): any {
if (!obj || !path) return undefined;
return path.split('.').reduce((acc: any, k: string) => acc?.[k], obj);
}
function formatForDisplay(v: any, componentName: string): any {
if (v === null || v === undefined) return '';
if (['NumberField', 'MoneyField'].includes(componentName)) {
return typeof v === 'number' ? v : Number(v) || 0;
}
if (componentName === 'DDDateField') {
const s = String(v);
return s.includes('T') ? s.split('T')[0] : s.split(' ')[0];
}
if (Array.isArray(v)) return v.join(',');
return String(v);
}
function getTableRows(label: string): Record<string, string>[] {
return tableValues[label] || [];
}
function displayValue(field: any): string {
const v = formValues[field.label];
if (v === undefined || v === null || v === '') return '';
if (field.componentName === 'DDDateRangeField' && Array.isArray(v)) return v.join(' ~ ');
if (field.componentName === 'DDMultiSelectField' && Array.isArray(v)) return v.join(', ');
return String(v);
}
// ══ 审批流预览 ══
async function loadPreview(flowId: string) {
previewLoading.value = true;
try {
const res = await previewFlowApprovers(flowId);
approverPreview.value = Array.isArray(res) ? res : [];
} catch { approverPreview.value = []; }
finally { previewLoading.value = false; }
}
// ══ 提交 ══
async function handleSubmit() {
if (!selectedFlowId.value) {
activeTab.value = 'flow';
createMessage.warning('请在「审批流配置」页签中选择一个审批流');
return Promise.reject();
}
const unresolvedNodes = approverPreview.value.filter(n => !n.allResolved);
for (const node of unresolvedNodes) {
const key = node.nodeId || String(approverPreview.value.indexOf(node));
if (!supplementPhones[key]?.trim()) {
activeTab.value = 'flow';
createMessage.warning(`${node.nodeType==='cc'?'抄送节点':'审批节点'}${node.nodeName}」有未解析成员,请补充手机号`);
return Promise.reject();
}
}
const fvList: { name: string; value: string }[] = [];
for (const field of dingFields.value) {
if (field.componentName === 'TextNote') continue;
const label = field.label;
if (field.componentName === 'TableField') {
const validRows = getTableRows(label).filter(r => Object.values(r).some(v => v !== ''));
if (validRows.length === 0) continue;
fvList.push({
name: label,
value: JSON.stringify(validRows.map(row =>
Object.entries(row).map(([k, v]) => ({ name: k, value: String(v ?? '') }))
)),
});
continue;
}
let val = formValues[label];
if (field.componentName === 'DDDateRangeField' && Array.isArray(val)) {
val = val.join('~');
} else if (field.componentName === 'DDMultiSelectField' && Array.isArray(val)) {
val = val.length > 0 ? JSON.stringify(val) : null;
} else if (['InnerContactField', 'RelateField', 'DDPhotoField'].includes(field.componentName)) {
const raw = val ? String(val).trim() : '';
val = raw ? JSON.stringify(raw.split(',').map((s: string) => s.trim()).filter(Boolean)) : null;
} else {
val = val !== undefined && val !== null ? String(val) : null;
}
if (val === null || val === '') { if (!field.required) continue; val = ''; }
fvList.push({ name: label, value: val as string });
}
const approverOverrides = Object.entries(supplementPhones)
.filter(([, phones]) => phones?.trim())
.map(([nodeId, phones]) => ({ nodeId, phones: phones.trim() }));
const row = allRows.value[currentRowIndex.value];
const flow = flowList.value.find((f) => f.id === selectedFlowId.value);
const bizTable = flow?.bizTable;
const bizDataId = row?.id != null ? String(row.id) : '';
const bizTitle = getRowLabel(row, currentRowIndex.value);
//update-begin---author:GHT ---date:2026-06-04 for【QH-MES审批台账】钉钉发起前统一门禁校验-----
if (bizTable && bizDataId) {
const gate = await checkCanLaunch({ bizTable, bizDataId });
if (!gate?.allowed) {
createMessage.warning(gate?.reason || '当前不允许发起审批');
return Promise.reject();
}
}
//update-end---author:GHT ---date:2026-06-04 for【QH-MES审批台账】钉钉发起前统一门禁校验-----
submitting.value = true;
try {
const result = await launchApproval({
id: tplData.value!.templateId,
formValues: fvList,
flowId: selectedFlowId.value,
approverOverrides,
bizTable,
bizDataId,
bizTitle,
bizCode: tplData.value?.bizCode,
});
createMessage.success(typeof result === 'string' ? result : '审批发起成功!审批人将在钉钉「待我审批」中收到任务');
visible.value = false;
emit('success', result);
} catch (e: any) {
createMessage.error(e?.message || '发起失败');
return Promise.reject(e);
} finally {
submitting.value = false;
}
}
</script>
<style lang="less" scoped>
.dal-body {
display: flex;
min-height: 480px;
max-height: 70vh;
}
.dal-timeline-panel {
width: 210px;
flex-shrink: 0;
background: #fafafa;
border-right: 1px solid #f0f0f0;
padding: 16px 14px;
overflow-y: auto;
}
.dal-panel-title {
font-size: 12px;
font-weight: 600;
color: #888;
letter-spacing: .5px;
text-transform: uppercase;
margin-bottom: 14px;
display: flex;
align-items: center;
gap: 6px;
}
.dal-timeline-empty {
text-align: center; color: #bbb; font-size: 12px; padding-top: 32px; line-height: 1.8;
.dal-timeline-empty-icon { font-size: 24px; margin-bottom: 8px; }
}
.dal-ts-step { display: flex; align-items: flex-start; gap: 8px; }
.dal-ts-left { display: flex; flex-direction: column; align-items: center; flex-shrink: 0; width: 12px; padding-top: 2px; }
.dal-ts-dot {
width: 10px; height: 10px; border-radius: 50%; flex-shrink: 0;
position: relative; z-index: 1; border: 2px solid currentColor; background: #fff;
&--start { color: #1677ff; background: #1677ff; border-color: #1677ff; }
&--end { color: #d9d9d9; background: #d9d9d9; border-color: #d9d9d9; width: 8px; height: 8px; }
&--approver { color: #fa8c16; }
&--cc { color: #1677ff; }
&--warn { color: #ff4d4f !important; }
}
.dal-ts-line { width: 2px; flex: 1; min-height: 22px; background: linear-gradient(to bottom, #e0e0e0 50%, transparent 100%); margin: 3px 0 0; }
.dal-ts-content { flex: 1; padding-bottom: 18px; min-width: 0; }
.dal-ts-sub { font-size: 11px; color: #aaa; }
.dal-ts-tags { display: flex; align-items: center; gap: 4px; margin-bottom: 2px; }
.dal-ts-badge {
font-size: 10px; padding: 0 5px; border-radius: 3px; line-height: 16px; font-weight: 500;
&--approver { background: #fff7e6; color: #d46b08; border: 1px solid #ffd591; }
&--cc { background: #e6f4ff; color: #0958d9; border: 1px solid #91caff; }
}
.dal-ts-mode { font-size: 10px; color: #aaa; background: #f5f5f5; padding: 0 4px; border-radius: 2px; line-height: 14px; }
.dal-ts-name { font-size: 12px; font-weight: 500; color: #333; line-height: 1.4; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.dal-ts-users { font-size: 11px; color: #888; margin-top: 2px; line-height: 1.5; }
.dal-ts-user--ok { color: #52c41a; }
.dal-ts-user--err { color: #ff4d4f; }
.dal-ts-unresolved { font-size: 10px; color: #ff7a00; margin-top: 2px; }
.dal-panel-divider { width: 1px; background: #f0f0f0; flex-shrink: 0; }
.dal-content-panel {
flex: 1; min-width: 0; display: flex; flex-direction: column; overflow: hidden;
}
.dal-tabs {
height: 100%; display: flex; flex-direction: column;
:deep(.ant-tabs-nav) { padding: 0 16px; margin-bottom: 0; }
:deep(.ant-tabs-content-holder) { flex: 1; overflow: hidden; }
:deep(.ant-tabs-content) { height: 100%; }
:deep(.ant-tabs-tabpane) { height: 100%; }
}
.dal-form-scroll { height: 100%; overflow-y: auto; padding: 14px 18px; }
.dal-form-item { margin-bottom: 14px; }
.dal-form-empty { color: #bbb; text-align: center; padding: 32px 0; font-size: 13px; }
.dal-field-label {
font-size: 13px; color: #555; margin-bottom: 5px; font-weight: 500;
&--required::before { content: '* '; color: #ff4d4f; }
}
.dal-field-hint { font-size: 11px; color: #aaa; margin-top: 3px; }
.dal-form-note {
background: #f8f8f8; border-left: 3px solid #ddd; padding: 6px 10px;
font-size: 12px; color: #777; margin-bottom: 12px; border-radius: 0 4px 4px 0;
}
.dal-readonly-input {
:deep(.ant-input[disabled]) {
color: rgba(0,0,0,.75) !important;
background: #f8f8f8 !important;
cursor: default;
}
}
.dal-readonly-cell {
display: block;
padding: 2px 6px;
color: rgba(0,0,0,.75);
font-size: 13px;
line-height: 1.5;
}
.dal-table-wrap { border: 1px solid #ebebeb; border-radius: 6px; overflow: hidden; }
.dal-table {
width: 100%; border-collapse: collapse; font-size: 13px;
th { background: #fafafa; padding: 7px 10px; border-bottom: 1px solid #ebebeb; font-weight: 500; color: #666; font-size: 12px; }
td { padding: 4px 6px; border-bottom: 1px solid #f5f5f5; vertical-align: middle; }
tr:last-child td { border-bottom: none; }
}
// 审批流配置
.flow-select-row { display: flex; gap: 8px; align-items: center; margin-bottom: 8px; }
.flow-readonly-link {
color: inherit;
text-decoration: none;
border-bottom: 1px dashed currentColor;
cursor: pointer;
transition: color .15s;
&:hover { color: #1677ff; }
}
.flow-opt-item { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
.flow-opt-name { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.flow-opt-remark { font-size: 11px; color: #aaa; }
.preview-title { font-size: 12px; font-weight: 600; color: #555; margin-bottom: 8px; display: flex; align-items: center; }
.preview-node {
background: #fafafa; border: 1px solid #f0f0f0; border-radius: 6px; padding: 8px 10px; margin-bottom: 8px;
&--cc { background: #f0f7ff; border-color: #bae0ff; }
}
.preview-node-hd { display: flex; align-items: center; gap: 4px; margin-bottom: 6px; }
.preview-node-name { font-size: 12px; font-weight: 500; flex: 1; }
.preview-node-mode { font-size: 11px; color: #aaa; }
.preview-user { margin-bottom: 4px; }
.preview-supplement { margin-top: 8px; }
// ══ 右侧:已选数据列表 ══
.dal-rows-panel {
width: 170px;
flex-shrink: 0;
background: #fafafa;
padding: 16px 10px;
overflow-y: auto;
}
.dal-rows-badge {
display: inline-flex;
align-items: center;
justify-content: center;
background: #ff6900;
color: #fff;
border-radius: 10px;
font-size: 10px;
font-weight: 700;
min-width: 16px;
height: 16px;
padding: 0 4px;
line-height: 1;
}
.dal-rows-list {
display: flex;
flex-direction: column;
gap: 4px;
}
.dal-row-item {
display: flex;
align-items: flex-start;
gap: 6px;
padding: 7px 8px;
border-radius: 6px;
cursor: pointer;
border: 1px solid transparent;
transition: all .15s;
background: #fff;
border-color: #f0f0f0;
&:hover {
border-color: #ff6900;
background: #fff8f3;
}
&--active {
border-color: #ff6900 !important;
background: #fff3eb !important;
.dal-row-index {
background: #ff6900;
color: #fff;
}
}
}
.dal-row-index {
flex-shrink: 0;
width: 18px;
height: 18px;
border-radius: 50%;
background: #e8e8e8;
color: #666;
font-size: 10px;
font-weight: 700;
display: flex;
align-items: center;
justify-content: center;
transition: all .15s;
margin-top: 1px;
}
.dal-row-label {
flex: 1;
min-width: 0;
font-size: 12px;
color: #333;
line-height: 1.5;
word-break: break-all;
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
}
</style>

View File

@@ -0,0 +1,257 @@
<!--
全局发起钉钉审批按钮
路由变化时检查当前页面是否配置了钉钉审批模板绑定有则显示
默认停靠在列表页查询条件区域最右侧可拖拽移动位置按路由 localStorage 记忆
@author GHT
@date 2026-06-04 forMESToDing审批配置审批模板绑定-发起钉钉审批
-->
<template>
<Teleport to="body">
<div
v-if="binding"
ref="floatRef"
class="dtl-float"
:class="{ 'dtl-float--dragging': isDragging }"
:style="floatStyle"
>
<div
class="dtl-float-btn"
:class="{ 'dtl-float-btn--active': hasRows }"
:title="btnTitle"
@pointerdown="onBtnPointerDown"
@click="onBtnClick"
>
<Icon icon="ant-design:dingtalk-outlined" :size="18" class="dtl-float-icon" />
<span class="dtl-float-text">钉钉审批</span>
<span v-if="hasRows" class="dtl-float-badge">{{ selectedRows.length }}</span>
</div>
</div>
</Teleport>
<DingBindLaunchModal ref="modalRef" />
</template>
<script lang="ts" setup>
import { ref, computed, watch, nextTick, onMounted, onUnmounted } from 'vue';
import { useRouter } from 'vue-router';
import { useMessage } from '/@/hooks/web/useMessage';
import { useApprovalSelection } from '/@/components/ApprovalLaunch/useApprovalSelection';
import { getBindingByRoute } from '/@/views/xslmes/dingtalk/dingTplBind/dingTplBind.api';
import DingBindLaunchModal from './DingBindLaunchModal.vue';
import { useDraggablePosition } from './useDraggablePosition';
defineOptions({ name: 'DingTplLaunchFloat' });
const { createMessage } = useMessage();
const router = useRouter();
const { getRowsByPath } = useApprovalSelection();
const binding = ref<any>(null);
const modalRef = ref<InstanceType<typeof DingBindLaunchModal> | null>(null);
const floatRef = ref<HTMLElement | null>(null);
const selectedRows = computed(() => getRowsByPath(router.currentRoute.value.path));
const hasRows = computed(() => selectedRows.value.length > 0);
const btnTitle = computed(() =>
hasRows.value
? `已选 ${selectedRows.value.length} 条,点击发起钉钉审批(可拖拽移动)`
: '请先在列表中勾选数据(可拖拽移动)',
);
const { pos, isDragging, initPosition, applyDefaultPosition, onPointerDown, wasDragged, clampPosition } =
useDraggablePosition(computed(() => router.currentRoute.value.path));
const floatStyle = computed(() => ({
left: `${pos.left}px`,
top: `${pos.top}px`,
}));
let resizeObserver: ResizeObserver | null = null;
let layoutTimer: ReturnType<typeof setTimeout> | null = null;
function scheduleLayout(preferDefault = false) {
if (layoutTimer) clearTimeout(layoutTimer);
layoutTimer = setTimeout(() => {
layoutTimer = null;
const path = router.currentRoute.value.path;
if (!binding.value || !path) return;
initPosition(path, preferDefault);
nextTick(() => {
if (floatRef.value) {
const rect = floatRef.value.getBoundingClientRect();
clampPosition();
}
});
}, 80);
}
function bindFormResize() {
unbindFormResize();
const formEl = document.querySelector('.jeecg-basic-table-form-container');
if (!formEl || typeof ResizeObserver === 'undefined') return;
resizeObserver = new ResizeObserver(() => {
const path = router.currentRoute.value.path;
const saved = localStorage.getItem('mes_ding_tpl_launch_pos');
const hasSaved = saved && JSON.parse(saved || '{}')[path];
if (!hasSaved) applyDefaultPosition();
});
resizeObserver.observe(formEl);
}
function unbindFormResize() {
resizeObserver?.disconnect();
resizeObserver = null;
}
watch(
() => router.currentRoute.value.path,
async (path) => {
binding.value = null;
unbindFormResize();
if (!path || path === '/' || path.startsWith('/login')) return;
try {
const result = await getBindingByRoute(path);
binding.value = result || null;
if (binding.value) {
await nextTick();
scheduleLayout(false);
bindFormResize();
}
} catch {
binding.value = null;
}
},
{ immediate: true },
);
onMounted(() => {
window.addEventListener('resize', onWindowResize);
});
onUnmounted(() => {
window.removeEventListener('resize', onWindowResize);
unbindFormResize();
if (layoutTimer) clearTimeout(layoutTimer);
});
function onWindowResize() {
if (!binding.value) return;
clampPosition();
const path = router.currentRoute.value.path;
try {
const all = JSON.parse(localStorage.getItem('mes_ding_tpl_launch_pos') || '{}');
if (path && all[path]) {
all[path] = { left: pos.left, top: pos.top };
localStorage.setItem('mes_ding_tpl_launch_pos', JSON.stringify(all));
}
} catch {
/* ignore */
}
}
function onBtnPointerDown(e: PointerEvent) {
onPointerDown(e, floatRef.value);
}
function onBtnClick() {
if (wasDragged()) return;
handleClick();
}
function handleClick() {
if (!binding.value) return;
const rows = selectedRows.value;
if (!rows.length) {
createMessage.warning('请先在列表中勾选要发起审批的数据');
return;
}
modalRef.value?.open(
{
id: binding.value.id,
templateId: binding.value.templateId,
templateName: binding.value.templateName || '',
fieldMappingJson: binding.value.fieldMappingJson,
bizCode: binding.value.bizCode,
},
rows,
);
}
</script>
<style scoped>
.dtl-float {
position: fixed;
z-index: 1001;
user-select: none;
touch-action: none;
}
.dtl-float--dragging {
z-index: 1002;
}
.dtl-float-btn {
display: flex;
align-items: center;
gap: 6px;
padding: 7px 14px;
background: rgba(255, 105, 0, 0.12);
border: 1.5px solid #ff6900;
border-radius: 24px;
color: #cc5500;
font-size: 13px;
font-weight: 600;
cursor: grab;
transition:
background 0.2s,
color 0.2s,
box-shadow 0.2s,
opacity 0.2s;
box-shadow: 0 2px 8px rgba(255, 105, 0, 0.15);
opacity: 0.85;
}
.dtl-float--dragging .dtl-float-btn {
cursor: grabbing;
opacity: 1;
box-shadow: 0 6px 18px rgba(255, 105, 0, 0.35);
}
.dtl-float-btn:hover,
.dtl-float-btn--active {
opacity: 1;
background: #ff6900;
color: #fff;
box-shadow: 0 4px 14px rgba(255, 105, 0, 0.4);
}
.dtl-float-btn:hover .dtl-float-icon,
.dtl-float-btn--active .dtl-float-icon {
color: #fff;
}
.dtl-float-icon {
color: #ff6900;
flex-shrink: 0;
}
.dtl-float-badge {
display: inline-flex;
align-items: center;
justify-content: center;
background: #fff;
color: #ff6900;
border-radius: 10px;
font-size: 11px;
font-weight: 700;
min-width: 18px;
height: 18px;
padding: 0 4px;
}
.dtl-float-btn--active .dtl-float-badge {
color: #ff6900;
}
</style>

View File

@@ -0,0 +1,157 @@
import { reactive, ref, type Ref } from 'vue';
const STORAGE_KEY = 'mes_ding_tpl_launch_pos';
export interface FloatPosition {
left: number;
top: number;
}
function readAllPositions(): Record<string, FloatPosition> {
try {
return JSON.parse(localStorage.getItem(STORAGE_KEY) || '{}') || {};
} catch {
return {};
}
}
/** 查询条件区域BasicTable 搜索表单)右侧的默认坐标 */
export function calcDefaultPosition(btnWidth: number, btnHeight: number): FloatPosition {
const formEl =
document.querySelector<HTMLElement>('.jeecg-basic-table-form-container .ant-form') ||
document.querySelector<HTMLElement>('.jeecg-basic-table-form-container');
if (!formEl) {
return {
left: Math.max(8, window.innerWidth - btnWidth - 24),
top: 100,
};
}
const rect = formEl.getBoundingClientRect();
return {
left: Math.max(8, rect.right - btnWidth - 12),
top: Math.max(8, rect.top + (rect.height - btnHeight) / 2),
};
}
/**
* 可拖拽悬浮按钮位置:默认对齐查询区右侧,拖拽后按路由持久化。
*/
export function useDraggablePosition(routePath: Ref<string>) {
const pos = reactive<FloatPosition>({ left: 0, top: 0 });
const isDragging = ref(false);
let moved = false;
let pointerId: number | null = null;
let startX = 0;
let startY = 0;
let startLeft = 0;
let startTop = 0;
let btnWidth = 120;
let btnHeight = 36;
function setButtonSize(width: number, height: number) {
btnWidth = width;
btnHeight = height;
}
function loadPosition(path: string): boolean {
const saved = readAllPositions()[path];
if (saved && typeof saved.left === 'number' && typeof saved.top === 'number') {
pos.left = saved.left;
pos.top = saved.top;
return true;
}
return false;
}
function savePosition(path: string) {
const all = readAllPositions();
all[path] = { left: pos.left, top: pos.top };
localStorage.setItem(STORAGE_KEY, JSON.stringify(all));
}
function applyDefaultPosition() {
const p = calcDefaultPosition(btnWidth, btnHeight);
pos.left = p.left;
pos.top = p.top;
}
function initPosition(path: string, preferDefault = false) {
if (!path) return;
if (!preferDefault && loadPosition(path)) return;
applyDefaultPosition();
}
function clampPosition() {
const maxLeft = window.innerWidth - btnWidth - 8;
const maxTop = window.innerHeight - btnHeight - 8;
pos.left = Math.min(Math.max(8, pos.left), maxLeft);
pos.top = Math.min(Math.max(8, pos.top), maxTop);
}
function onPointerMove(e: PointerEvent) {
if (pointerId !== e.pointerId) return;
const dx = e.clientX - startX;
const dy = e.clientY - startY;
if (!moved && (Math.abs(dx) > 4 || Math.abs(dy) > 4)) {
moved = true;
isDragging.value = true;
}
if (!moved) return;
pos.left = startLeft + dx;
pos.top = startTop + dy;
clampPosition();
}
function endDrag(path: string) {
document.removeEventListener('pointermove', onPointerMove);
document.removeEventListener('pointerup', onPointerUp);
document.removeEventListener('pointercancel', onPointerUp);
if (moved && path) {
savePosition(path);
}
pointerId = null;
setTimeout(() => {
isDragging.value = false;
moved = false;
}, 0);
}
function onPointerUp(e: PointerEvent) {
if (pointerId !== e.pointerId) return;
endDrag(routePath.value);
}
function onPointerDown(e: PointerEvent, el: HTMLElement | null) {
if (e.button !== 0) return;
if (el) {
const rect = el.getBoundingClientRect();
setButtonSize(rect.width, rect.height);
}
moved = false;
isDragging.value = false;
pointerId = e.pointerId;
startX = e.clientX;
startY = e.clientY;
startLeft = pos.left;
startTop = pos.top;
(e.currentTarget as HTMLElement)?.setPointerCapture?.(e.pointerId);
document.addEventListener('pointermove', onPointerMove);
document.addEventListener('pointerup', onPointerUp);
document.addEventListener('pointercancel', onPointerUp);
}
function wasDragged() {
return moved;
}
return {
pos,
isDragging,
setButtonSize,
initPosition,
applyDefaultPosition,
onPointerDown,
wasDragged,
clampPosition,
};
}

View File

@@ -13,6 +13,9 @@
<!-- update-begin---author:GHT ---date:2026-05-29 forQH-MES审批流设计全局发起审批悬浮按钮----- -->
<ApprovalLaunchFloat />
<!-- update-end---author:GHT ---date:2026-05-29 forQH-MES审批流设计全局发起审批悬浮按钮----- -->
<!-- update-begin---author:GHT ---date:2026-06-04 forMESToDing审批配置全局钉钉审批模板绑定发起按钮----- -->
<DingTplLaunchFloat />
<!-- update-end---author:GHT ---date:2026-06-04 forMESToDing审批配置全局钉钉审批模板绑定发起按钮----- -->
<!-- update-begin---author:GHT ---date:2026-05-29 forQH-MES审批流设计全局审批流程设计悬浮按钮----- -->
<ApprovalDesignFloat />
<!-- update-end---author:GHT ---date:2026-05-29 forQH-MES审批流设计全局审批流程设计悬浮按钮----- -->
@@ -48,6 +51,9 @@
//update-begin---author:GHT ---date:2026-05-29 for【QH-MES审批流设计】全局审批流程设计悬浮按钮-----
ApprovalDesignFloat: createAsyncComponent(() => import('/@/components/ApprovalDesign/index.vue')),
//update-end---author:GHT ---date:2026-05-29 for【QH-MES审批流设计】全局审批流程设计悬浮按钮-----
//update-begin---author:GHT ---date:2026-06-04 for【MESToDing审批配置】全局钉钉审批模板绑定发起按钮-----
DingTplLaunchFloat: createAsyncComponent(() => import('/@/components/DingTplLaunch/index.vue')),
//update-end---author:GHT ---date:2026-06-04 for【MESToDing审批配置】全局钉钉审批模板绑定发起按钮-----
LayoutHeader,
LayoutContent,
LayoutSideBar,

View File

@@ -0,0 +1,31 @@
import { defHttp } from '/@/utils/http/axios';
enum Api {
canLaunch = '/xslmes/approvalGate/canLaunch',
canLaunchBatch = '/xslmes/approvalGate/canLaunchBatch',
history = '/xslmes/approvalGate/history',
}
export interface ApprovalGateVo {
allowed?: boolean;
reason?: string;
bizTable?: string;
bizDataId?: string;
latestRecordId?: string;
latestStatus?: string;
latestChannel?: string;
latestChannelText?: string;
latestStatusText?: string;
}
/** 检查是否允许发起审批 */
export const checkCanLaunch = (params: { bizTable: string; bizDataId: string }) =>
defHttp.get<ApprovalGateVo>({ url: Api.canLaunch, params });
/** 批量检查是否允许发起审批 */
export const checkCanLaunchBatch = (params: { bizTable: string; bizDataIds: string[] }) =>
defHttp.post<ApprovalGateVo[]>({ url: Api.canLaunchBatch, params });
/** 查询业务单据审批台账历史 */
export const getApprovalHistory = (params: { bizTable: string; bizDataId: string }) =>
defHttp.get({ url: Api.history, params });

View File

@@ -64,6 +64,19 @@ export const thirdAppFormSchema: FormSchema[] = [
unCheckedValue: 0
},
defaultValue: 1
},{
label: 'Stream事件推送',
field: 'streamEnabled',
component: 'Switch',
ifShow: ({ values }) => values.thirdType === 'dingtalk',
componentProps: {
checkedChildren: '已启用',
checkedValue: 1,
unCheckedChildren: '未启用',
unCheckedValue: 0,
},
helpMessage: '开启后,此应用的 AppKey/AppSecret 将用于接收钉钉 Stream 事件推送(审批结果等)。同一企业只需开启一个',
defaultValue: 0,
},{
label: '租户id',
field: 'tenantId',

View File

@@ -45,6 +45,14 @@
<a-input-password v-model:value="appConfigData.clientSecret" readonly />
</div>
</div>
<div class="flex-flow">
<div class="base-title">Stream推送</div>
<div class="base-message" style="display:flex;align-items:center;height:50px;">
<a-tag :color="appConfigData.streamEnabled === 1 ? 'green' : 'default'">
{{ appConfigData.streamEnabled === 1 ? '已设为Stream主配置' : '未启用' }}
</a-tag>
</div>
</div>
<div style="margin-top: 20px; width: 100%; text-align: right">
<a-button @click="dingEditClick">编辑</a-button>
<a-button v-if="appConfigData.id" @click="cancelBindClick" danger style="margin-left: 10px">取消绑定</a-button>

View File

@@ -0,0 +1,13 @@
import { defHttp } from '/@/utils/http/axios';
enum Api {
list = '/xslmes/mesXslApprovalRecord/list',
queryById = '/xslmes/mesXslApprovalRecord/queryById',
exportXls = '/xslmes/mesXslApprovalRecord/exportXls',
}
export const getExportUrl = Api.exportXls;
export const list = (params) => defHttp.get({ url: Api.list, params });
export const queryById = (params: { id: string }) => defHttp.get({ url: Api.queryById, params });

View File

@@ -0,0 +1,78 @@
import { BasicColumn, FormSchema } from '/@/components/Table';
export const columns: BasicColumn[] = [
{ title: '业务单据', align: 'center', dataIndex: 'bizTableName', width: 120, ellipsis: true },
{ title: '业务标题', align: 'center', dataIndex: 'bizTitle', width: 180, ellipsis: true },
{ title: '审批通道', align: 'center', dataIndex: 'channel_dictText', width: 100 },
{ title: '状态', align: 'center', dataIndex: 'status_dictText', width: 100 },
{ title: '发起次数', align: 'center', dataIndex: 'launchNo', width: 80 },
{ title: '发起人', align: 'center', dataIndex: 'applyUserName', width: 100 },
{
title: '发起时间',
align: 'center',
dataIndex: 'applyTime',
width: 165,
customRender: ({ text }) => (!text ? '' : String(text).length > 19 ? String(text).substring(0, 19) : text),
},
{
title: '办结时间',
align: 'center',
dataIndex: 'finishTime',
width: 165,
customRender: ({ text }) => (!text ? '' : String(text).length > 19 ? String(text).substring(0, 19) : text),
},
{ title: 'MES审批流', align: 'center', dataIndex: 'flowName', width: 140, ellipsis: true },
{ title: '钉钉模板', align: 'center', dataIndex: 'templateName', width: 140, ellipsis: true },
{ title: '外部实例ID', align: 'center', dataIndex: 'externalInstanceId', width: 160, ellipsis: true },
{ title: '备注', align: 'center', dataIndex: 'remark', width: 160, ellipsis: true },
];
export const searchFormSchema: FormSchema[] = [
{ label: '业务单据', field: 'bizTableName', component: 'Input', colProps: { span: 6 } },
{ label: '业务标题', field: 'bizTitle', component: 'Input', colProps: { span: 6 } },
{
label: '审批通道',
field: 'channel',
component: 'JDictSelectTag',
componentProps: { dictCode: 'mes_xsl_approval_channel', placeholder: '请选择' },
colProps: { span: 6 },
},
{
label: '状态',
field: 'status',
component: 'JDictSelectTag',
componentProps: { dictCode: 'mes_xsl_approval_record_status', placeholder: '请选择' },
colProps: { span: 6 },
},
{ label: '发起人', field: 'applyUserName', component: 'Input', colProps: { span: 6 } },
];
export const detailFormSchema: FormSchema[] = [
{ label: '', field: 'id', component: 'Input', show: false },
{ label: '业务单据', field: 'bizTableName', component: 'Input', componentProps: { readonly: true } },
{ label: '业务表名', field: 'bizTable', component: 'Input', componentProps: { readonly: true } },
{ label: '业务数据ID', field: 'bizDataId', component: 'Input', componentProps: { readonly: true } },
{ label: '业务标题', field: 'bizTitle', component: 'Input', componentProps: { readonly: true } },
{ label: '业务编码', field: 'bizCode', component: 'Input', componentProps: { readonly: true } },
{
label: '审批通道',
field: 'channel',
component: 'JDictSelectTag',
componentProps: { dictCode: 'mes_xsl_approval_channel', disabled: true },
},
{
label: '状态',
field: 'status',
component: 'JDictSelectTag',
componentProps: { dictCode: 'mes_xsl_approval_record_status', disabled: true },
},
{ label: '发起次数', field: 'launchNo', component: 'InputNumber', componentProps: { disabled: true, style: { width: '100%' } } },
{ label: '发起人账号', field: 'applyUser', component: 'Input', componentProps: { readonly: true } },
{ label: '发起人姓名', field: 'applyUserName', component: 'Input', componentProps: { readonly: true } },
{ label: '发起时间', field: 'applyTime', component: 'Input', componentProps: { readonly: true } },
{ label: '办结时间', field: 'finishTime', component: 'Input', componentProps: { readonly: true } },
{ label: 'MES审批流', field: 'flowName', component: 'Input', componentProps: { readonly: true } },
{ label: '钉钉模板', field: 'templateName', component: 'Input', componentProps: { readonly: true } },
{ label: '外部实例ID', field: 'externalInstanceId', component: 'Input', componentProps: { readonly: true } },
{ label: '备注', field: 'remark', component: 'InputTextArea', componentProps: { rows: 3, readonly: true } },
];

View File

@@ -0,0 +1,68 @@
<template>
<div>
<BasicTable @register="registerTable">
<template #tableTitle>
<a-button
type="primary"
v-auth="'xslmes:mes_xsl_approval_record:exportXls'"
preIcon="ant-design:export-outlined"
@click="onExportXls"
>
导出
</a-button>
</template>
<template #action="{ record }">
<TableAction
:actions="[
{
label: '详情',
onClick: handleDetail.bind(null, record),
auth: 'xslmes:mes_xsl_approval_record:list',
},
]"
/>
</template>
</BasicTable>
<MesXslApprovalRecordDetailModal @register="registerModal" />
</div>
</template>
<script lang="ts" name="xslmes-mesXslApprovalRecord" setup>
import { BasicTable, TableAction } from '/@/components/Table';
import { useModal } from '/@/components/Modal';
import { useListPage } from '/@/hooks/system/useListPage';
import MesXslApprovalRecordDetailModal from './components/MesXslApprovalRecordDetailModal.vue';
import { columns, searchFormSchema } from './MesXslApprovalRecord.data';
import { list, getExportUrl } from './MesXslApprovalRecord.api';
const [registerModal, { openModal }] = useModal();
const { tableContext, onExportXls } = useListPage({
tableProps: {
title: '审批台账',
api: list,
columns,
canResize: true,
formConfig: {
schemas: searchFormSchema,
labelWidth: 100,
autoSubmitOnEnter: true,
showAdvancedButton: true,
},
actionColumn: {
width: 100,
fixed: 'right',
},
},
exportConfig: {
name: 'MES审批台账',
url: getExportUrl,
},
});
const [registerTable] = tableContext;
function handleDetail(record: Recordable) {
openModal(true, { record });
}
</script>

View File

@@ -0,0 +1,33 @@
<template>
<BasicModal v-bind="$attrs" @register="registerModal" title="审批台账详情" width="720px" :showOkBtn="false" cancelText="关闭">
<BasicForm @register="registerForm" />
</BasicModal>
</template>
<script lang="ts" setup>
import { ref, unref } from 'vue';
import { BasicModal, useModalInner } from '/@/components/Modal';
import { BasicForm, useForm } from '/@/components/Form';
import { detailFormSchema } from '../MesXslApprovalRecord.data';
import { queryById } from '../MesXslApprovalRecord.api';
const [registerForm, { setFieldsValue, resetFields }] = useForm({
labelWidth: 110,
schemas: detailFormSchema,
showActionButtonGroup: false,
baseColProps: { span: 24 },
});
const recordId = ref('');
const [registerModal, { setModalProps }] = useModalInner(async (data) => {
await resetFields();
setModalProps({ confirmLoading: false });
recordId.value = data?.record?.id || '';
if (!unref(recordId)) {
return;
}
const res = await queryById({ id: unref(recordId) });
await setFieldsValue({ ...res });
});
</script>

View File

@@ -0,0 +1,38 @@
import { defHttp } from '/@/utils/http/axios';
const BASE = '/xslmes/dingTplBind';
export const getMenuTree = () => defHttp.get({ url: `${BASE}/menuTree` });
export const getTplList = () => defHttp.get({ url: `${BASE}/tplList` });
export const getBizFields = (bizCode: string) =>
defHttp.get({ url: `${BASE}/bizFields`, params: { bizCode } });
export const getDetailSlots = (bizCode: string) =>
defHttp.get({ url: `${BASE}/detailSlots`, params: { bizCode } });
export const getDetailFields = (bizCode: string, detailProperty: string, slotKind = 'LIST') =>
defHttp.get({ url: `${BASE}/detailFields`, params: { bizCode, detailProperty, slotKind } });
export const getBindList = () => defHttp.get({ url: `${BASE}/list` });
export const getBindingByRoute = (routePath: string) =>
defHttp.get({ url: `${BASE}/bindingByRoute`, params: { routePath } }, { errorMessageMode: 'none' });
export const getByBizCode = (bizCode: string) =>
defHttp.get({ url: `${BASE}/getByBizCode`, params: { bizCode } });
export const saveBind = (data: {
bizCode: string;
bizName?: string;
templateId: string;
fieldMappingJson: string;
}) => defHttp.post({ url: `${BASE}/save`, data });
export const deleteBind = (id: string) =>
defHttp.delete({ url: `${BASE}/delete`, params: { id } });
/** 复用现有接口:拉取钉钉模板表单字段(含 dingFields */
export const getTemplateDetail = (id: string) =>
defHttp.get({ url: '/xslmes/mesXslDingProcessTpl/getTemplateDetail', params: { id } });

File diff suppressed because it is too large Load Diff

View File

@@ -15,6 +15,7 @@ enum Api {
batchImport = '/xslmes/mesXslDingProcessTpl/batchImport',
getTemplateDetail = '/xslmes/mesXslDingProcessTpl/getTemplateDetail',
saveFieldMapping = '/xslmes/mesXslDingProcessTpl/saveFieldMapping',
addNewTemplate = '/xslmes/mesXslDingProcessTpl/addNewTemplate',
createDingTemplate = '/xslmes/mesXslDingProcessTpl/createDingTemplate',
updateDingTemplate = '/xslmes/mesXslDingProcessTpl/updateDingTemplate',
launchApproval = '/xslmes/mesXslDingProcessTpl/launchApproval',
@@ -46,6 +47,10 @@ export const batchDelete = (params, handleSuccess) => {
export const saveOrUpdate = (params, isUpdate) =>
defHttp.post({ url: isUpdate ? Api.edit : Api.save, params }, { successMessageMode: 'none' });
/** 新增审批模板草稿(返回含 id 的完整记录) */
export const addNewTemplate = (params) =>
defHttp.post({ url: Api.addNewTemplate, params }, { successMessageMode: 'none' });
export const syncFromDingtalk = () => defHttp.get({ url: Api.syncFromDingtalk }, { successMessageMode: 'none' });
export const batchImport = (params) => defHttp.post({ url: Api.batchImport, params }, { successMessageMode: 'none' });

View File

@@ -51,8 +51,9 @@ export const formSchema: FormSchema[] = [
label: 'processCode',
field: 'processCode',
component: 'Input',
componentProps: { placeholder: '请输入钉钉processCode如 PROC-XXXXXXXX-...' },
dynamicRules: () => [{ required: true, message: '请输入processCode!' }],
componentProps: {
placeholder: '钉钉返回的 processCode;「新增审批模板」流程中可留空,设计器创建钉钉模板后自动回填',
},
},
{
label: '业务类型标识',

View File

@@ -2,8 +2,18 @@
<div>
<BasicTable @register="registerTable" :rowSelection="rowSelection">
<template #tableTitle>
<a-button type="primary" v-auth="'xslmes:mes_xsl_ding_process_tpl:add'" preIcon="ant-design:plus-outlined" @click="handleAdd">
新增
<!--update-begin---author:GHT ---date:2026-06-04 forMESToDing审批配置新增审批模板创建草稿+打开设计器-->
<a-button
type="primary"
v-auth="'xslmes:mes_xsl_ding_process_tpl:add'"
preIcon="ant-design:dingtalk-outlined"
@click="handleAddNewTemplate"
>
新增审批模板
</a-button>
<!--update-end---author:GHT ---date:2026-06-04 forMESToDing审批配置新增审批模板创建草稿+打开设计器-->
<a-button v-auth="'xslmes:mes_xsl_ding_process_tpl:add'" preIcon="ant-design:plus-outlined" @click="handleAdd">
快速录入
</a-button>
<a-button type="primary" v-auth="'xslmes:mes_xsl_ding_process_tpl:exportXls'" preIcon="ant-design:export-outlined" @click="onExportXls">
导出
@@ -88,6 +98,7 @@
<!--update-begin---author:GHT ---date:2026-06-03 forMESToDing审批配置表单设计器-->
<DingTplDesigner ref="designerRef" @success="handleSuccess" />
<DingTplCreateModal ref="createModalRef" @success="onNewTemplateCreated" />
<!--update-end---author:GHT ---date:2026-06-03 forMESToDing审批配置表单设计器-->
<!--update-begin---author:GHT ---date:2026-06-03 forMESToDing审批配置手动填表发起钉钉审批-->
@@ -119,7 +130,8 @@
>
<template #bodyCell="{ column, record }">
<template v-if="column.dataIndex === 'imported'">
<a-tag :color="record.imported ? 'green' : 'default'">{{ record.imported ? '已导入' : '未导入' }}</a-tag>
<a-tag v-if="record.linkDraft" color="orange">待回填本地</a-tag>
<a-tag v-else :color="record.imported ? 'green' : 'default'">{{ record.imported ? '已导入' : '未导入' }}</a-tag>
</template>
</template>
</a-table>
@@ -138,6 +150,7 @@
import Icon from '/@/components/Icon';
import MesXslDingProcessTplModal from './components/MesXslDingProcessTplModal.vue';
import DingTplDesigner from './components/DingTplDesigner.vue';
import DingTplCreateModal from './components/DingTplCreateModal.vue';
//update-begin---author:GHT ---date:2026-06-03 for【MESToDing审批配置】手动填表发起钉钉审批
import DingApprovalLaunchModal from './components/DingApprovalLaunchModal.vue';
//update-end---author:GHT ---date:2026-06-03 for【MESToDing审批配置】手动填表发起钉钉审批
@@ -184,6 +197,19 @@
openModal(true, { isUpdate: false, showFooter: true });
}
const createModalRef = ref();
function handleAddNewTemplate() {
createModalRef.value?.open();
}
function onNewTemplateCreated({ record, openDesigner }: { record: Recordable; openDesigner: boolean }) {
reload();
if (openDesigner && record?.id) {
designerRef.value?.open(record);
}
}
function handleEdit(record: Recordable) {
openModal(true, { record, isUpdate: true, showFooter: true });
}
@@ -221,10 +247,20 @@
}
function getDropDownAction(record) {
return [
const actions: any[] = [
{ label: '详情', onClick: handleDetail.bind(null, record) },
//update-begin---author:GHT ---date:2026-06-03 for【MESToDing审批配置】新增设计模板入口
{ label: '设计模板', onClick: handleDesignTemplate.bind(null, record), icon: 'ant-design:layout-outlined' },
];
if (!record.processCode) {
actions.push({
label: '创建钉钉模板',
icon: 'ant-design:dingtalk-outlined',
onClick: handleDesignTemplate.bind(null, record),
auth: 'xslmes:mes_xsl_ding_process_tpl:edit',
});
}
actions.push(
{ label: '查看钉钉字段', onClick: handleShowDingSchema.bind(null, record), icon: 'ant-design:dingtalk-outlined' },
//update-end---author:GHT ---date:2026-06-03 for【MESToDing审批配置】新增设计模板入口
{
@@ -232,7 +268,8 @@
popConfirm: { title: '是否确认删除', confirm: handleDelete.bind(null, record), placement: 'topLeft' },
auth: 'xslmes:mes_xsl_ding_process_tpl:delete',
},
];
);
return actions;
}
// ===== 手动填表发起钉钉审批 =====
@@ -306,7 +343,9 @@
try {
const data = await syncFromDingtalk();
syncList.value = data || [];
syncSelectedKeys.value = (syncList.value as any[]).filter((r) => !r.imported).map((r) => r.processCode);
syncSelectedKeys.value = (syncList.value as any[])
.filter((r) => !r.imported || r.linkDraft)
.map((r) => r.processCode);
} catch (e: any) {
createMessage.error(e?.message || '从钉钉同步失败');
syncVisible.value = false;

View File

@@ -0,0 +1,110 @@
<!--
新增审批模板创建本地草稿并可选打开表单设计器在设计器中推送到钉钉
@author GHT
@date 2026-06-04 forMESToDing审批配置新增审批模板入口
-->
<template>
<a-modal
v-model:open="visible"
title="新增审批模板"
width="520px"
:confirmLoading="loading"
okText="创建并设计表单"
cancelText="取消"
destroy-on-close
@ok="handleSubmit"
@cancel="visible = false"
>
<a-alert
type="info"
show-icon
style="margin-bottom: 16px"
message="将先在 MES 创建模板配置随后在表单设计器中添加字段并点击「创建钉钉模板」推送到钉钉processCode 由钉钉返回)。"
/>
<a-form ref="formRef" :model="formState" :rules="rules" layout="vertical">
<a-form-item label="模板名称" name="tplName">
<a-input v-model:value="formState.tplName" placeholder="如密炼PS编制审批" allow-clear />
</a-form-item>
<a-form-item label="业务类型标识" name="bizType">
<a-input v-model:value="formState.bizType" placeholder="供审批流关联,如 mixer_ps" allow-clear />
</a-form-item>
<a-form-item label="备注" name="remark">
<a-textarea v-model:value="formState.remark" :rows="2" placeholder="可选" />
</a-form-item>
<a-form-item name="openDesigner">
<a-checkbox v-model:checked="formState.openDesigner">创建成功后打开表单设计器</a-checkbox>
</a-form-item>
</a-form>
</a-modal>
</template>
<script lang="ts" setup>
import { reactive, ref } from 'vue';
import type { FormInstance } from 'ant-design-vue';
import { useMessage } from '/@/hooks/web/useMessage';
import { addNewTemplate } from '../MesXslDingProcessTpl.api';
const emit = defineEmits<{
(e: 'success', payload: { record: Recordable; openDesigner: boolean }): void;
}>();
const { createMessage } = useMessage();
const visible = ref(false);
const loading = ref(false);
const formRef = ref<FormInstance>();
const formState = reactive({
tplName: '',
bizType: '',
remark: '',
openDesigner: true,
});
const rules = {
tplName: [{ required: true, message: '请输入模板名称', trigger: 'blur' }],
};
function resetForm() {
formState.tplName = '';
formState.bizType = '';
formState.remark = '';
formState.openDesigner = true;
formRef.value?.clearValidate();
}
function open() {
resetForm();
visible.value = true;
}
async function handleSubmit() {
try {
await formRef.value?.validate();
} catch {
return;
}
loading.value = true;
try {
const record: any = await addNewTemplate({
tplName: formState.tplName.trim(),
bizType: formState.bizType?.trim() || undefined,
remark: formState.remark?.trim() || undefined,
status: '1',
sortNo: 0,
});
if (!record?.id) {
createMessage.error('创建失败:未返回模板 ID');
return;
}
createMessage.success('审批模板已创建');
visible.value = false;
emit('success', { record, openDesigner: formState.openDesigner });
} catch (e: any) {
createMessage.error(e?.message || '创建失败');
} finally {
loading.value = false;
}
}
defineExpose({ open });
</script>

View File

@@ -747,7 +747,11 @@
try {
await saveFieldMapping({ id: tplData.value.id, formFields: serializeComponents() });
const res: any = await createDingTemplate({ id: tplData.value.id });
const processCode = res?.processCode || res?.result?.processCode;
const processCode = res?.processCode;
if (!processCode) {
createMessage.warning('钉钉已创建,但未拿到 processCode请稍后使用「从钉钉同步」回填');
return;
}
createMessage.success('钉钉模板创建成功processCode=' + processCode);
if (processCode) tplData.value = { ...tplData.value, processCode };
emit('success');

View File

@@ -12,7 +12,6 @@ enum Api {
proofread = '/xslmes/mesXslMixerPsCompile/proofread',
audit = '/xslmes/mesXslMixerPsCompile/audit',
approve = '/xslmes/mesXslMixerPsCompile/approve',
ddApprovalTest = '/xslmes/mesXslMixerPsCompile/ddApprovalTest',
}
export const list = (params) => defHttp.get({ url: Api.list, params });
@@ -38,5 +37,3 @@ export const proofread = (params: { ids: string }) => defHttp.post({ url: Api.pr
export const audit = (params: { ids: string }) => defHttp.post({ url: Api.audit, params }, { joinParamsToUrl: true });
export const approve = (params: { ids: string }) => defHttp.post({ url: Api.approve, params }, { joinParamsToUrl: true });
export const ddApprovalTest = () => defHttp.post({ url: Api.ddApprovalTest }, { successMessageMode: 'none' });

View File

@@ -65,11 +65,6 @@
</a-button>
</a-dropdown>
<super-query :config="superQueryConfig" @search="handleSuperQuery" />
<!--update-begin---author:GHT ---date:2026-06-03 for钉钉PROC-GENERIC可行性验证测试按钮-->
<a-button :loading="ddTestLoading" preIcon="ant-design:dingtalk-outlined" @click="handleDdApprovalTest">
钉钉审批测试
</a-button>
<!--update-end---author:GHT ---date:2026-06-03 for钉钉PROC-GENERIC可行性验证测试按钮-->
</template>
<template #action="{ record }">
<TableAction
@@ -86,19 +81,11 @@
</template>
</BasicTable>
<MesXslMixerPsCompileModal @register="registerModal" @success="handleSuccess" />
<!--update-begin---author:GHT ---date:2026-06-03 for钉钉PROC-GENERIC可行性验证结果弹窗-->
<a-modal v-model:open="ddTestVisible" title="钉钉审批测试结果" width="680px" :footer="null">
<a-spin :spinning="ddTestLoading">
<pre style="max-height: 520px; overflow: auto; font-size: 12px; background: #f6f8fa; padding: 14px; border-radius: 6px; white-space: pre-wrap; word-break: break-all">{{ ddTestResult }}</pre>
</a-spin>
</a-modal>
<!--update-end---author:GHT ---date:2026-06-03 for钉钉PROC-GENERIC可行性验证结果弹窗-->
</div>
</template>
<script lang="ts" name="xslmes-mesXslMixerPsCompile" setup>
import { computed, reactive, ref } from 'vue';
import { computed, reactive } from 'vue';
import { Modal } from 'ant-design-vue';
import { BasicTable, TableAction } from '/@/components/Table';
import { useModal } from '/@/components/Modal';
@@ -116,7 +103,6 @@
proofread,
audit,
approve,
ddApprovalTest,
} from './MesXslMixerPsCompile.api';
const { createMessage } = useMessage();
@@ -242,26 +228,6 @@
selectedRowKeys.value = [];
}
//update-begin---author:GHT ---date:2026-06-03 for【钉钉PROC-GENERIC可行性验证】测试逻辑
const ddTestVisible = ref(false);
const ddTestLoading = ref(false);
const ddTestResult = ref('');
async function handleDdApprovalTest() {
ddTestVisible.value = true;
ddTestLoading.value = true;
ddTestResult.value = '正在请求,请稍候...';
try {
const res = await ddApprovalTest();
ddTestResult.value = JSON.stringify(res, null, 2);
} catch (e: any) {
ddTestResult.value = '请求失败:\n' + (e?.message || JSON.stringify(e));
} finally {
ddTestLoading.value = false;
}
}
//update-end---author:GHT ---date:2026-06-03 for【钉钉PROC-GENERIC可行性验证】测试逻辑
function getDropDownAction(record: Recordable) {
return [
{ label: '详情', onClick: handleDetail.bind(null, record) },