Merge branch '20260519-3.9.2版本-葛昊天分支'

This commit is contained in:
geht
2026-06-05 10:45:42 +08:00
86 changed files with 9783 additions and 54 deletions

View File

@@ -677,7 +677,37 @@ 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
-- author:jiangxh---date:20250602--for: 【MES】设备台账原设备编号改为自定义编号、新增001自增只读系统编号 ---
jeecg-boot/jeecg-module-system/jeecg-system-start/src/main/resources/flyway/sql/mysql/V3.9.2_122__mes_xsl_equipment_ledger_ledger_no.sql

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

@@ -44,6 +44,9 @@ public class MesXslMixerPsCompileController extends JeecgController<MesXslMixerP
@Autowired
private ISysDepartService sysDepartService;
//update-begin---author:GHT ---date:2026-06-03 for【钉钉PROC-GENERIC可行性验证】注入钉钉服务-----
//update-end---author:GHT ---date:2026-06-03 for【钉钉PROC-GENERIC可行性验证】注入钉钉服务-----
@Operation(summary = "MES密炼PS编制-分页列表查询")
@GetMapping(value = "/list")
public Result<IPage<MesXslMixerPsCompile>> queryPageList(

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,43 @@
package org.jeecg.modules.xslmes.dingtalk.dto;
import lombok.Data;
import lombok.experimental.Accessors;
import java.util.List;
/**
* 钉钉审批表单控件
* 对应官方 SDKFormComponent
*
* 支持的 componentType
* TextField 单行文本
* TextareaField 多行文本
* NumberField 数字输入
* DDSelectField 单选
* DDMultiSelectField 多选
* DDDateField 日期
* DDDateRangeField 时间区间
* DDPhotoField 图片
* DDAttachment 附件
* DepartmentField 部门
* InnerContactField 联系人
* TextNote 说明文字
* MoneyField 金额
* PhoneField 电话
* AddressField 省市区
* StarRatingField 评分
* TableField 明细(含子控件 children
*/
@Data
@Accessors(chain = true)
public class DingFormComponent {
/** 控件类型 */
private String componentType;
/** 控件属性 */
private DingFormComponentProps props;
/** 子控件列表,仅 TableField 使用 */
private List<DingFormComponent> children;
}

View File

@@ -0,0 +1,99 @@
package org.jeecg.modules.xslmes.dingtalk.dto;
import lombok.Data;
import lombok.experimental.Accessors;
import java.util.List;
/**
* 钉钉审批表单控件属性
* 对应官方 SDKFormComponentProps
*/
@Data
@Accessors(chain = true)
public class DingFormComponentProps {
/** 控件唯一标识,建议格式:{componentType}-{bizKey} */
private String componentId;
/** 控件标题(钉钉表单中显示的字段名) */
private String label;
/** 占位提示文字 */
private String placeholder;
/** 是否必填 */
private Boolean required;
/** 日期格式DDDateField/DDDateRangeField 使用,如 "yyyy-MM-dd" */
private String format;
/** 单位NumberField/DDDateField/DDDateRangeField 使用 */
private String unit;
/** 说明内容TextNote 使用 */
private String content;
/** 说明文字超链接TextNote 使用 */
private String link;
/** 是否参与打印("0"=否TextNote 使用 */
private String print;
/** 单选/多选选项列表DDSelectField/DDMultiSelectField 使用 */
private List<SelectOption> options;
/** 业务别名DDSelectField 使用(如 "staff_type" */
private String bizAlias;
/** 金额大写显示("0"=否 "1"=是MoneyField 使用 */
private String upper;
/** 电话模式("phone"PhoneField 使用 */
private String mode;
/** 联系人选择模式("1"=多选InnerContactField 使用 */
private String choice;
/** 部门是否多选DepartmentField 使用 */
private Boolean multiple;
/** 省市区精度("city"=市级 "district"=区级AddressField 使用 */
private String addressModel;
/** 评分最大值StarRatingField 使用 */
private Integer limit;
/** 明细视图模式("table"/"list"TableField 使用 */
private String tableViewMode;
/** 明细打印方向true=纵向 false=横向TableField 使用 */
private Boolean verticalPrint;
/** 明细汇总字段TableField 使用 */
private List<StatField> statField;
/** 可关联的审批单列表RelateField 使用 */
private List<AvailableTemplate> availableTemplates;
@Data
@Accessors(chain = true)
public static class SelectOption {
private String key;
private String value;
}
@Data
@Accessors(chain = true)
public static class StatField {
private String componentId;
private String label;
}
@Data
@Accessors(chain = true)
public static class AvailableTemplate {
private String name;
private String processCode;
}
}

View File

@@ -0,0 +1,25 @@
package org.jeecg.modules.xslmes.dingtalk.dto;
import lombok.Data;
import lombok.experimental.Accessors;
import java.util.List;
/**
* 创建钉钉审批模板请求体
* POST https://api.dingtalk.com/v1.0/workflow/forms
* 对应官方 SDKFormCreateRequest
*/
@Data
@Accessors(chain = true)
public class DingFormCreateRequest {
/** 模板名称 */
private String name;
/** 模板描述 */
private String description;
/** 表单控件列表 */
private List<DingFormComponent> formComponents;
}

View File

@@ -0,0 +1,28 @@
package org.jeecg.modules.xslmes.dingtalk.dto;
import lombok.Data;
import lombok.experimental.Accessors;
import java.util.List;
/**
* 更新钉钉审批模板请求体
* PUT https://api.dingtalk.com/v1.0/workflow/forms
* 对应官方 SDKFormUpdateRequest
*/
@Data
@Accessors(chain = true)
public class DingFormUpdateRequest {
/** 要更新的模板 processCode */
private String processCode;
/** 模板名称 */
private String name;
/** 模板描述 */
private String description;
/** 表单控件列表 */
private List<DingFormComponent> formComponents;
}

View File

@@ -0,0 +1,73 @@
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.aspect.annotation.Dict;
import org.jeecg.common.system.base.entity.JeecgEntity;
import org.jeecgframework.poi.excel.annotation.Excel;
import java.io.Serializable;
/**
* 钉钉审批模板配置
*
* @author GHT
* @date 2026-06-03 for【MESToDing审批配置】钉钉审批模板配置
*/
@Data
@TableName("mes_xsl_ding_process_tpl")
@Accessors(chain = true)
@EqualsAndHashCode(callSuper = false)
@Schema(description = "钉钉审批模板配置")
public class MesXslDingProcessTpl extends JeecgEntity implements Serializable {
private static final long serialVersionUID = 1L;
@Excel(name = "模板名称", width = 20)
@Schema(description = "模板名称")
private String tplName;
@Excel(name = "钉钉processCode", width = 35)
@Schema(description = "钉钉processCode")
private String processCode;
@Excel(name = "业务类型标识", width = 20)
@Schema(description = "业务类型标识(供审批流关联使用)")
private String bizType;
@Excel(name = "表单字段映射", width = 30)
@Schema(description = "表单字段映射JSON(钉钉字段名→MES字段名)")
private String formFields;
@Excel(name = "状态", width = 10, dicCode = "mes_ding_tpl_status")
@Dict(dicCode = "mes_ding_tpl_status")
@Schema(description = "状态:0停用 1启用")
private String status;
@Excel(name = "排序", width = 10)
@Schema(description = "排序")
private Integer sortNo;
@Excel(name = "备注", width = 30)
@Schema(description = "备注")
private String remark;
//update-begin---author:GHT ---date:2026-06-04 for【MESToDing审批配置】绑定MES审批流审批人来源-----------
@Schema(description = "绑定的MES审批流ID(用于发起审批时解析审批人)")
private String flowId;
//update-end---author:GHT ---date:2026-06-04 for【MESToDing审批配置】绑定MES审批流审批人来源-----------
@Schema(description = "逻辑删除:0正常 1已删除")
@TableLogic
private Integer delFlag;
@Schema(description = "租户ID")
private Integer tenantId;
@Schema(description = "所属部门编码")
private String sysOrgCode;
}

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.MesXslDingProcessTpl;
/**
* 钉钉审批模板配置 Mapper
*
* @author GHT
* @date 2026-06-03
*/
public interface MesXslDingProcessTplMapper extends BaseMapper<MesXslDingProcessTpl> {
}

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.MesXslDingProcessTplMapper">
</mapper>

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,13 @@
package org.jeecg.modules.xslmes.dingtalk.service;
import com.baomidou.mybatisplus.extension.service.IService;
import org.jeecg.modules.xslmes.dingtalk.entity.MesXslDingProcessTpl;
/**
* 钉钉审批模板配置 Service 接口
*
* @author GHT
* @date 2026-06-03
*/
public interface IMesXslDingProcessTplService extends IService<MesXslDingProcessTpl> {
}

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,18 @@
package org.jeecg.modules.xslmes.dingtalk.service.impl;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import org.jeecg.modules.xslmes.dingtalk.entity.MesXslDingProcessTpl;
import org.jeecg.modules.xslmes.dingtalk.mapper.MesXslDingProcessTplMapper;
import org.jeecg.modules.xslmes.dingtalk.service.IMesXslDingProcessTplService;
import org.springframework.stereotype.Service;
/**
* 钉钉审批模板配置 ServiceImpl
*
* @author GHT
* @date 2026-06-03
*/
@Service
public class MesXslDingProcessTplServiceImpl extends ServiceImpl<MesXslDingProcessTplMapper, MesXslDingProcessTpl>
implements IMesXslDingProcessTplService {
}

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,74 @@
-- ============================================================
-- MESToDing审批配置 - 钉钉审批模板配置
-- author: GHT date: 2026-06-03
-- ============================================================
-- ========== 建表 DDL ==========
CREATE TABLE IF NOT EXISTS `mes_xsl_ding_process_tpl` (
`id` varchar(32) NOT NULL COMMENT '主键',
`tpl_name` varchar(100) NOT NULL COMMENT '模板名称',
`process_code` varchar(100) NOT NULL COMMENT '钉钉processCode',
`biz_type` varchar(50) DEFAULT NULL COMMENT '业务类型标识(供审批流关联使用)',
`form_fields` text DEFAULT NULL COMMENT '表单字段映射JSON(钉钉字段名MES字段名)',
`status` char(1) NOT NULL DEFAULT '1' COMMENT '状态:0停用 1启用',
`sort_no` int NOT NULL DEFAULT 0 COMMENT '排序',
`remark` varchar(500) 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 '更新时间',
`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`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='MES-钉钉审批模板配置';
-- ========== 字典 mes_ding_tpl_status ==========
INSERT INTO `sys_dict` (`id`, `dict_name`, `dict_code`, `description`, `del_flag`, `create_by`, `create_time`, `update_by`, `update_time`, `type`, `tenant_id`)
VALUES (REPLACE(UUID(),'-',''), '钉钉审批模板状态', 'mes_ding_tpl_status', '钉钉审批模板启用/停用状态', 0, 'admin', NOW(), NULL, NULL, 0, 0);
INSERT INTO `sys_dict_item` (`id`, `dict_id`, `item_text`, `item_value`, `description`, `sort_order`, `status`, `create_by`, `create_time`, `update_by`, `update_time`)
SELECT REPLACE(UUID(),'-',''), id, '启用', '1', NULL, 1, 1, 'admin', NOW(), NULL, NULL FROM `sys_dict` WHERE `dict_code` = 'mes_ding_tpl_status' LIMIT 1;
INSERT INTO `sys_dict_item` (`id`, `dict_id`, `item_text`, `item_value`, `description`, `sort_order`, `status`, `create_by`, `create_time`, `update_by`, `update_time`)
SELECT REPLACE(UUID(),'-',''), id, '停用', '0', NULL, 2, 1, 'admin', NOW(), NULL, NULL FROM `sys_dict` WHERE `dict_code` = 'mes_ding_tpl_status' LIMIT 1;
-- ========== 菜单权限 ==========
-- 注意该页面对应的前台目录为 views/xslmes/dingtalk/mesXslDingProcessTpl 文件夹下
-- 父菜单MESToDing审批配置目录级is_leaf=0
INSERT INTO sys_permission(id, parent_id, name, url, component, component_name, redirect, menu_type, perms, perms_type, sort_no, always_show, icon, is_route, is_leaf, keep_alive, hidden, hide_tab, description, status, del_flag, rule_flag, create_by, create_time, update_by, update_time, internal_or_external)
VALUES ('178046026420801', NULL, 'MESToDing审批配置', '/mestoding', 'layouts/RouteView', NULL, NULL, 0, NULL, '1', 99.00, 0, 'ant-design:dingtalk-outlined', 1, 0, 0, 0, 0, NULL, '1', 0, 0, 'admin', '2026-06-03 00:00:00', NULL, NULL, 0);
-- 子菜单钉钉审批模板配置is_leaf=0有按钮子级
INSERT INTO sys_permission(id, parent_id, name, url, component, component_name, redirect, menu_type, perms, perms_type, sort_no, always_show, icon, is_route, is_leaf, keep_alive, hidden, hide_tab, description, status, del_flag, rule_flag, create_by, create_time, update_by, update_time, internal_or_external)
VALUES ('178046026420802', '178046026420801', '钉钉审批模板配置', '/xslmes/mesXslDingProcessTplList', 'xslmes/dingtalk/mesXslDingProcessTpl/MesXslDingProcessTplList', NULL, NULL, 0, NULL, '1', 1.00, 0, NULL, 1, 0, 0, 0, 0, NULL, '1', 0, 0, 'admin', '2026-06-03 00:00:00', NULL, NULL, 0);
-- 按钮权限parent_id = 178046026420802is_leaf=1
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 ('178046026420803', '178046026420802', '添加钉钉审批模板配置', NULL, NULL, 0, NULL, NULL, 2, 'xslmes:mes_xsl_ding_process_tpl:add', '1', NULL, 0, NULL, 1, 0, 0, 0, NULL, 'admin', '2026-06-03 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 ('178046026420804', '178046026420802', '编辑钉钉审批模板配置', NULL, NULL, 0, NULL, NULL, 2, 'xslmes:mes_xsl_ding_process_tpl:edit', '1', NULL, 0, NULL, 1, 0, 0, 0, NULL, 'admin', '2026-06-03 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 ('178046026420805', '178046026420802', '删除钉钉审批模板配置', NULL, NULL, 0, NULL, NULL, 2, 'xslmes:mes_xsl_ding_process_tpl:delete', '1', NULL, 0, NULL, 1, 0, 0, 0, NULL, 'admin', '2026-06-03 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 ('178046026420806', '178046026420802', '批量删除钉钉审批模板配置', NULL, NULL, 0, NULL, NULL, 2, 'xslmes:mes_xsl_ding_process_tpl:deleteBatch', '1', NULL, 0, NULL, 1, 0, 0, 0, NULL, 'admin', '2026-06-03 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 ('178046026420807', '178046026420802', '导出excel_钉钉审批模板配置', NULL, NULL, 0, NULL, NULL, 2, 'xslmes:mes_xsl_ding_process_tpl:exportXls', '1', NULL, 0, NULL, 1, 0, 0, 0, NULL, 'admin', '2026-06-03 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 ('178046026420808', '178046026420802', '导入excel_钉钉审批模板配置', NULL, NULL, 0, NULL, NULL, 2, 'xslmes:mes_xsl_ding_process_tpl:importExcel', '1', NULL, 0, NULL, 1, 0, 0, 0, NULL, 'admin', '2026-06-03 00:00:00', NULL, NULL, 0, 0, '1', 0);
-- ========== admin 角色授权role_id = f6817f48af4fb3af11b9e8bf182f618b==========
INSERT INTO sys_role_permission (id, role_id, permission_id, data_rule_ids, operate_date, operate_ip) VALUES (REPLACE(UUID(),'-',''), 'f6817f48af4fb3af11b9e8bf182f618b', '178046026420801', NULL, '2026-06-03 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', '178046026420802', NULL, '2026-06-03 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', '178046026420803', NULL, '2026-06-03 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', '178046026420804', NULL, '2026-06-03 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', '178046026420805', NULL, '2026-06-03 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', '178046026420806', NULL, '2026-06-03 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', '178046026420807', NULL, '2026-06-03 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', '178046026420808', NULL, '2026-06-03 00:00:00', '127.0.0.1');

View File

@@ -0,0 +1,6 @@
-- ============================================================
-- 钉钉审批模板配置 - 新增绑定MES审批流字段
-- author: GHT date: 2026-06-04
-- ============================================================
ALTER TABLE `mes_xsl_ding_process_tpl`
ADD COLUMN `flow_id` varchar(32) DEFAULT NULL COMMENT '绑定的MES审批流ID(用于发起审批时解析审批人)';

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未推送钉钉前为空';