diff --git a/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/doc/代码修改日志 b/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/doc/代码修改日志 index 181abf3..a36f156 100644 --- a/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/doc/代码修改日志 +++ b/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/doc/代码修改日志 @@ -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 diff --git a/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/pom.xml b/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/pom.xml index e6b3b7f..4b46e8f 100644 --- a/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/pom.xml +++ b/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/pom.xml @@ -19,6 +19,12 @@ jeecg-boot-base-core ${jeecgboot.version} + + + com.dingtalk.open + dingtalk-stream + 1.3.12 + org.jeecgframework.boot3 diff --git a/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/callback/ApprovalActionHttpExecutor.java b/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/callback/ApprovalActionHttpExecutor.java index ac2d1f7..f03604b 100644 --- a/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/callback/ApprovalActionHttpExecutor.java +++ b/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/callback/ApprovalActionHttpExecutor.java @@ -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审批流设计】驳回统一回退:按表注解自动调用业务接口----------- /** diff --git a/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/constant/ApprovalRecordConstants.java b/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/constant/ApprovalRecordConstants.java new file mode 100644 index 0000000..795f4d7 --- /dev/null +++ b/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/constant/ApprovalRecordConstants.java @@ -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"; +} diff --git a/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/controller/MesXslApprovalGateController.java b/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/controller/MesXslApprovalGateController.java new file mode 100644 index 0000000..0d91d2e --- /dev/null +++ b/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/controller/MesXslApprovalGateController.java @@ -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 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> 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 list = approvalGateService.checkCanLaunchBatch(req.getBizTable().trim(), req.getBizDataIds(), tenantId); + return Result.OK(list); + } + + @Operation(summary = "查询业务单据审批台账历史") + @GetMapping("/history") + public Result> 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 bizDataIds; + } +} diff --git a/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/controller/MesXslApprovalLaunchController.java b/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/controller/MesXslApprovalLaunchController.java index 59e8245..0f63b01 100644 --- a/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/controller/MesXslApprovalLaunchController.java +++ b/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/controller/MesXslApprovalLaunchController.java @@ -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() - .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() - .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审批台账】发起后同步台账终态(如无节点自动通过)----- } diff --git a/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/controller/MesXslApprovalRecordController.java b/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/controller/MesXslApprovalRecordController.java new file mode 100644 index 0000000..98fa62a --- /dev/null +++ b/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/controller/MesXslApprovalRecordController.java @@ -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 { + + @Autowired + private IMesXslApprovalRecordService mesXslApprovalRecordService; + + @Operation(summary = "MES审批台账-分页列表查询") + @RequiresPermissions("xslmes:mes_xsl_approval_record:list") + @GetMapping(value = "/list") + public Result> queryPageList( + MesXslApprovalRecord model, + @RequestParam(name = "pageNo", defaultValue = "1") Integer pageNo, + @RequestParam(name = "pageSize", defaultValue = "10") Integer pageSize, + HttpServletRequest req) { + QueryWrapper queryWrapper = QueryGenerator.initQueryWrapper(model, req.getParameterMap()); + queryWrapper.orderByDesc("apply_time").orderByDesc("create_time"); + Page page = new Page<>(pageNo, pageSize); + IPage 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 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审批台账"); + } +} diff --git a/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/entity/MesXslApprovalRecord.java b/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/entity/MesXslApprovalRecord.java new file mode 100644 index 0000000..f5ab335 --- /dev/null +++ b/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/entity/MesXslApprovalRecord.java @@ -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; +} diff --git a/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/mapper/MesXslApprovalRecordMapper.java b/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/mapper/MesXslApprovalRecordMapper.java new file mode 100644 index 0000000..f69533a --- /dev/null +++ b/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/mapper/MesXslApprovalRecordMapper.java @@ -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 { +} diff --git a/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/service/IMesXslApprovalGateService.java b/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/service/IMesXslApprovalGateService.java new file mode 100644 index 0000000..38b3f6a --- /dev/null +++ b/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/service/IMesXslApprovalGateService.java @@ -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 checkCanLaunchBatch(String bizTable, List 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 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); +} diff --git a/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/service/IMesXslApprovalRecordService.java b/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/service/IMesXslApprovalRecordService.java new file mode 100644 index 0000000..e78ab64 --- /dev/null +++ b/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/service/IMesXslApprovalRecordService.java @@ -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 { +} diff --git a/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/service/impl/MesXslApprovalGateServiceImpl.java b/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/service/impl/MesXslApprovalGateServiceImpl.java new file mode 100644 index 0000000..b410be5 --- /dev/null +++ b/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/service/impl/MesXslApprovalGateServiceImpl.java @@ -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 checkCanLaunchBatch(String bizTable, List bizDataIds, Integer tenantId) { + List 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 uw = new LambdaUpdateWrapper() + .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 listHistory(String bizTable, String bizDataId, Integer tenantId) { + if (oConvertUtils.isEmpty(bizTable) || oConvertUtils.isEmpty(bizDataId)) { + return List.of(); + } + LambdaQueryWrapper 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 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 qw = baseBizQuery(bizTable, bizDataId, tenantId); + qw.orderByDesc(MesXslApprovalRecord::getApplyTime).orderByDesc(MesXslApprovalRecord::getCreateTime).last("LIMIT 1"); + return recordService.getOne(qw, false); + } + + private LambdaQueryWrapper baseBizQuery(String bizTable, String bizDataId, Integer tenantId) { + LambdaQueryWrapper 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 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, ""); + } +} diff --git a/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/service/impl/MesXslApprovalHandleServiceImpl.java b/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/service/impl/MesXslApprovalHandleServiceImpl.java index c15e895..1a77479 100644 --- a/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/service/impl/MesXslApprovalHandleServiceImpl.java +++ b/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/service/impl/MesXslApprovalHandleServiceImpl.java @@ -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审批办结同步台账----- } diff --git a/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/service/impl/MesXslApprovalRecordServiceImpl.java b/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/service/impl/MesXslApprovalRecordServiceImpl.java new file mode 100644 index 0000000..bcdc245 --- /dev/null +++ b/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/service/impl/MesXslApprovalRecordServiceImpl.java @@ -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 + implements IMesXslApprovalRecordService { +} diff --git a/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/vo/ApprovalGateVo.java b/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/vo/ApprovalGateVo.java new file mode 100644 index 0000000..e51de04 --- /dev/null +++ b/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/vo/ApprovalGateVo.java @@ -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; +} diff --git a/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/controller/MesXslMixerPsCompileController.java b/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/controller/MesXslMixerPsCompileController.java index 1c6da68..8b4a26e 100644 --- a/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/controller/MesXslMixerPsCompileController.java +++ b/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/controller/MesXslMixerPsCompileController.java @@ -44,6 +44,9 @@ public class MesXslMixerPsCompileController extends JeecgController> queryPageList( diff --git a/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/dingtalk/controller/MesXslDingProcessTplController.java b/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/dingtalk/controller/MesXslDingProcessTplController.java new file mode 100644 index 0000000..2bc6dcb --- /dev/null +++ b/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/dingtalk/controller/MesXslDingProcessTplController.java @@ -0,0 +1,1713 @@ +package org.jeecg.modules.xslmes.dingtalk.controller; + +import com.alibaba.fastjson2.JSON; +import com.alibaba.fastjson2.JSONArray; +import com.alibaba.fastjson2.JSONObject; +import org.jeecg.modules.xslmes.approval.entity.MesXslApprovalFlow; +import org.jeecg.modules.xslmes.approval.service.IMesXslApprovalFlowService; +import org.jeecg.modules.xslmes.approval.service.IMesXslApprovalGateService; +import org.jeecg.modules.xslmes.dingtalk.dto.DingFormComponent; +import org.jeecg.modules.xslmes.dingtalk.dto.DingFormComponentProps; +import org.jeecg.modules.xslmes.dingtalk.dto.DingFormCreateRequest; +import org.jeecg.modules.xslmes.dingtalk.dto.DingFormUpdateRequest; +import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper; +import com.baomidou.mybatisplus.core.metadata.IPage; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.jeecg.dingtalk.api.core.response.Response; +import com.jeecg.dingtalk.api.user.JdtUserAPI; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.extern.slf4j.Slf4j; +import org.apache.shiro.SecurityUtils; +import org.apache.shiro.authz.annotation.RequiresPermissions; +import org.jeecg.common.config.TenantContext; +import org.jeecg.common.constant.CommonConstant; +import org.jeecg.common.api.vo.Result; +import org.jeecg.common.aspect.annotation.AutoLog; +import org.jeecg.common.system.base.controller.JeecgController; +import org.jeecg.common.system.query.QueryGenerator; +import org.jeecg.common.system.query.QueryRuleEnum; +import org.jeecg.common.system.vo.LoginUser; +import org.jeecg.common.util.oConvertUtils; +import org.jeecg.modules.system.entity.SysThirdAccount; +import org.jeecg.modules.system.service.ISysThirdAccountService; +import org.jeecg.modules.system.service.impl.ThirdAppDingtalkServiceImpl; +import org.jeecg.modules.xslmes.dingtalk.entity.MesXslDingProcessTpl; +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 org.springframework.web.servlet.ModelAndView; + +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.text.SimpleDateFormat; +import java.time.Duration; +import java.util.*; + +/** + * 钉钉审批模板配置 + * + * @author GHT + * @date 2026-06-03 for:【MESToDing审批配置】钉钉审批模板配置 + */ +@Tag(name = "钉钉审批模板配置") +@RestController +@RequestMapping("/xslmes/mesXslDingProcessTpl") +@Slf4j +public class MesXslDingProcessTplController extends JeecgController { + + @Autowired + private IMesXslDingProcessTplService mesXslDingProcessTplService; + + @Autowired + private ThirdAppDingtalkServiceImpl dingtalkService; + + @Autowired + private ISysThirdAccountService sysThirdAccountService; + + //update-begin---author:GHT ---date:2026-06-04 for:【MESToDing审批配置】绑定MES审批流(审批人来源)----------- + @Autowired + private IMesXslApprovalFlowService approvalFlowService; + + @Autowired + private JdbcTemplate jdbcTemplate; + + @Autowired + private IMesXslApprovalGateService approvalGateService; + //update-end---author:GHT ---date:2026-06-04 for:【MESToDing审批配置】绑定MES审批流(审批人来源)----------- + + @Operation(summary = "钉钉审批模板配置-分页列表查询") + @GetMapping(value = "/list") + public Result> queryPageList(MesXslDingProcessTpl mesXslDingProcessTpl, + @RequestParam(name = "pageNo", defaultValue = "1") Integer pageNo, + @RequestParam(name = "pageSize", defaultValue = "10") Integer pageSize, + HttpServletRequest req) { + Map customeRuleMap = new HashMap<>(); + customeRuleMap.put("status", QueryRuleEnum.LIKE_WITH_OR); + QueryWrapper queryWrapper = QueryGenerator.initQueryWrapper( + mesXslDingProcessTpl, req.getParameterMap(), customeRuleMap); + queryWrapper.orderByAsc("sort_no").orderByDesc("create_time"); + Page page = new Page<>(pageNo, pageSize); + IPage pageList = mesXslDingProcessTplService.page(page, queryWrapper); + return Result.OK(pageList); + } + + @AutoLog(value = "钉钉审批模板配置-添加") + @Operation(summary = "钉钉审批模板配置-添加") + @RequiresPermissions("xslmes:mes_xsl_ding_process_tpl:add") + @PostMapping(value = "/add") + public Result add(@RequestBody MesXslDingProcessTpl mesXslDingProcessTpl) { + if (oConvertUtils.isEmpty(mesXslDingProcessTpl.getProcessCode())) { + mesXslDingProcessTpl.setProcessCode(""); + } + mesXslDingProcessTplService.save(mesXslDingProcessTpl); + return Result.OK("添加成功!"); + } + + //update-begin---author:GHT ---date:2026-06-04 for:【MESToDing审批配置】新增审批模板(本地草稿,processCode 由后续创建钉钉模板回填)----- + @AutoLog(value = "钉钉审批模板配置-新增审批模板草稿") + @Operation(summary = "钉钉审批模板配置-新增审批模板(返回含 id 的记录,供打开表单设计器)") + @RequiresPermissions("xslmes:mes_xsl_ding_process_tpl:add") + @PostMapping(value = "/addNewTemplate") + public Result addNewTemplate(@RequestBody MesXslDingProcessTpl body) { + if (body == null || oConvertUtils.isEmpty(body.getTplName())) { + return Result.error("模板名称不能为空"); + } + if (oConvertUtils.isEmpty(body.getStatus())) { + body.setStatus("1"); + } + if (body.getSortNo() == null) { + body.setSortNo(0); + } + if (oConvertUtils.isEmpty(body.getFormFields())) { + body.setFormFields("[]"); + } + // process_code 列 NOT NULL,草稿阶段用空串占位,创建钉钉模板后回填真实 processCode + if (oConvertUtils.isEmpty(body.getProcessCode())) { + body.setProcessCode(""); + } + mesXslDingProcessTplService.save(body); + return Result.OK("审批模板已创建,请继续设计表单并推送到钉钉", body); + } + //update-end---author:GHT ---date:2026-06-04 for:【MESToDing审批配置】新增审批模板(本地草稿,processCode 由后续创建钉钉模板回填)----- + + @AutoLog(value = "钉钉审批模板配置-编辑") + @Operation(summary = "钉钉审批模板配置-编辑") + @RequiresPermissions("xslmes:mes_xsl_ding_process_tpl:edit") + @RequestMapping(value = "/edit", method = {RequestMethod.PUT, RequestMethod.POST}) + public Result edit(@RequestBody MesXslDingProcessTpl mesXslDingProcessTpl) { + mesXslDingProcessTplService.updateById(mesXslDingProcessTpl); + return Result.OK("编辑成功!"); + } + + @AutoLog(value = "钉钉审批模板配置-通过id删除") + @Operation(summary = "钉钉审批模板配置-通过id删除") + @RequiresPermissions("xslmes:mes_xsl_ding_process_tpl:delete") + @DeleteMapping(value = "/delete") + public Result delete(@RequestParam(name = "id", required = true) String id) { + mesXslDingProcessTplService.removeById(id); + return Result.OK("删除成功!"); + } + + @AutoLog(value = "钉钉审批模板配置-批量删除") + @Operation(summary = "钉钉审批模板配置-批量删除") + @RequiresPermissions("xslmes:mes_xsl_ding_process_tpl:deleteBatch") + @DeleteMapping(value = "/deleteBatch") + public Result deleteBatch(@RequestParam(name = "ids", required = true) String ids) { + this.mesXslDingProcessTplService.removeByIds(Arrays.asList(ids.split(","))); + return Result.OK("批量删除成功!"); + } + + @Operation(summary = "钉钉审批模板配置-通过id查询") + @GetMapping(value = "/queryById") + public Result queryById(@RequestParam(name = "id", required = true) String id) { + MesXslDingProcessTpl entity = mesXslDingProcessTplService.getById(id); + if (entity == null) { + return Result.error("未找到对应数据"); + } + return Result.OK(entity); + } + + @RequiresPermissions("xslmes:mes_xsl_ding_process_tpl:exportXls") + @RequestMapping(value = "/exportXls") + public ModelAndView exportXls(HttpServletRequest request, MesXslDingProcessTpl mesXslDingProcessTpl) { + return super.exportXls(request, mesXslDingProcessTpl, MesXslDingProcessTpl.class, "钉钉审批模板配置"); + } + + @RequiresPermissions("xslmes:mes_xsl_ding_process_tpl:importExcel") + @RequestMapping(value = "/importExcel", method = RequestMethod.POST) + public Result importExcel(HttpServletRequest request, HttpServletResponse response) { + return super.importExcel(request, response, MesXslDingProcessTpl.class); + } + + //update-begin---author:GHT ---date:2026-06-03 for:【MESToDing审批配置】从钉钉同步审批模板列表----- + @Operation(summary = "钉钉审批模板配置-从钉钉拉取审批模板列表") + @GetMapping(value = "/syncFromDingtalk") + public Result>> syncFromDingtalk() { + LoginUser loginUser = (LoginUser) SecurityUtils.getSubject().getPrincipal(); + + // ① 获取 AccessToken + String accessToken = dingtalkService.getAccessToken(); + if (oConvertUtils.isEmpty(accessToken)) { + return Result.error("钉钉 AccessToken 获取失败,请检查[系统配置-第三方应用]中的钉钉配置"); + } + + // ② 获取当前用户的钉钉 userId(优先从 sys_third_account 查,其次用手机号降级) + String dtUserId = null; + int tenantId = oConvertUtils.getInt(TenantContext.getTenant(), CommonConstant.TENANT_ID_DEFAULT_VALUE); + List accounts = sysThirdAccountService.listThirdUserIdByUsername( + new String[]{loginUser.getUsername()}, "dingtalk", tenantId); + if (accounts != null && !accounts.isEmpty() && oConvertUtils.isNotEmpty(accounts.get(0).getThirdUserId())) { + dtUserId = accounts.get(0).getThirdUserId(); + } else if (oConvertUtils.isNotEmpty(loginUser.getPhone())) { + Response resp = JdtUserAPI.getUseridByMobile(loginUser.getPhone(), accessToken); + if (resp.isSuccess() && oConvertUtils.isNotEmpty(resp.getResult())) { + dtUserId = resp.getResult(); + } + } + if (oConvertUtils.isEmpty(dtUserId)) { + return Result.error("未能获取当前用户的钉钉 userId,请先完成钉钉账号绑定或确认手机号已在企业钉钉中注册"); + } + + // ③ 调用钉钉接口获取可见审批模板列表 + try { + String url = "https://oapi.dingtalk.com/topapi/process/listbyuserid?access_token=" + accessToken; + JSONObject reqBody = new JSONObject(); + reqBody.put("userid", dtUserId); + reqBody.put("offset", 0); + reqBody.put("size", 100); + + HttpClient httpClient = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(10)).build(); + HttpRequest httpReq = HttpRequest.newBuilder() + .uri(URI.create(url)) + .header("Content-Type", "application/json") + .POST(HttpRequest.BodyPublishers.ofString(reqBody.toJSONString())) + .timeout(Duration.ofSeconds(10)) + .build(); + String respBody = httpClient.send(httpReq, HttpResponse.BodyHandlers.ofString()).body(); + + JSONObject ddResp = JSONObject.parseObject(respBody); + if (ddResp.getIntValue("errcode") != 0) { + return Result.error("钉钉接口返回错误: " + ddResp.getString("errmsg") + + " (errcode=" + ddResp.getIntValue("errcode") + ")"); + } + + JSONObject result = ddResp.getJSONObject("result"); + JSONArray processList = result == null ? null : result.getJSONArray("process_list"); + + //update-begin---author:GHT ---date:2026-06-04 for:【MESToDing审批配置】同步列表:已导入判定含 processCode 与同名本地草稿----- + Set existCodes = new HashSet<>(); + Map draftIdByTplName = new HashMap<>(); + for (MesXslDingProcessTpl t : mesXslDingProcessTplService.list()) { + if (oConvertUtils.isNotEmpty(t.getProcessCode())) { + existCodes.add(t.getProcessCode()); + } else if (oConvertUtils.isNotEmpty(t.getTplName())) { + draftIdByTplName.put(t.getTplName().trim(), t.getId()); + } + } + + List> list = new ArrayList<>(); + if (processList != null) { + for (int i = 0; i < processList.size(); i++) { + JSONObject item = processList.getJSONObject(i); + String code = item.getString("process_code"); + String name = oConvertUtils.getString(item.getString("name"), "").trim(); + Map row = new LinkedHashMap<>(); + row.put("processCode", code); + row.put("name", name); + row.put("description", item.getString("description")); + boolean imported = oConvertUtils.isNotEmpty(code) && existCodes.contains(code); + String localDraftId = (!imported && oConvertUtils.isNotEmpty(name)) + ? draftIdByTplName.get(name) : null; + row.put("imported", imported || localDraftId != null); + row.put("localDraftId", localDraftId); + row.put("linkDraft", localDraftId != null && !imported); + list.add(row); + } + } + //update-end---author:GHT ---date:2026-06-04 for:【MESToDing审批配置】同步列表:已导入判定含 processCode 与同名本地草稿----- + return Result.OK(list); + } catch (Exception e) { + log.error("从钉钉同步审批模板失败", e); + return Result.error("请求钉钉接口异常: " + e.getMessage()); + } + } + + @Operation(summary = "钉钉审批模板配置-批量导入钉钉模板") + @PostMapping(value = "/batchImport") + public Result batchImport(@RequestBody List> items) { + if (items == null || items.isEmpty()) { + return Result.error("请选择要导入的模板"); + } + //update-begin---author:GHT ---date:2026-06-04 for:【MESToDing审批配置】批量导入:优先回填同名本地草稿,避免重复记录----- + Set existCodes = new HashSet<>(); + for (MesXslDingProcessTpl t : mesXslDingProcessTplService.list()) { + if (oConvertUtils.isNotEmpty(t.getProcessCode())) { + existCodes.add(t.getProcessCode()); + } + } + + List toSave = new ArrayList<>(); + int linkedCount = 0; + for (Map item : items) { + String code = String.valueOf(item.getOrDefault("processCode", "")).trim(); + String name = String.valueOf(item.getOrDefault("name", "")).trim(); + String localDraftId = item.get("localDraftId") != null ? String.valueOf(item.get("localDraftId")) : null; + if (oConvertUtils.isEmpty(code) || existCodes.contains(code)) { + continue; + } + MesXslDingProcessTpl draft = null; + if (oConvertUtils.isNotEmpty(localDraftId)) { + draft = mesXslDingProcessTplService.getById(localDraftId); + } + if (draft == null && oConvertUtils.isNotEmpty(name)) { + draft = findDraftWithoutProcessCode(name); + } + if (draft != null && oConvertUtils.isEmpty(draft.getProcessCode())) { + MesXslDingProcessTpl update = new MesXslDingProcessTpl(); + update.setId(draft.getId()); + update.setProcessCode(code); + if (oConvertUtils.isNotEmpty(name)) { + update.setTplName(name); + } + mesXslDingProcessTplService.updateById(update); + existCodes.add(code); + linkedCount++; + continue; + } + MesXslDingProcessTpl tpl = new MesXslDingProcessTpl(); + tpl.setProcessCode(code); + tpl.setTplName(name); + tpl.setStatus("1"); + tpl.setSortNo(0); + toSave.add(tpl); + existCodes.add(code); + } + if (toSave.isEmpty() && linkedCount == 0) { + return Result.OK("所选模板均已存在,无需重复导入"); + } + if (!toSave.isEmpty()) { + mesXslDingProcessTplService.saveBatch(toSave); + } + String msg = "成功导入 " + toSave.size() + " 个模板"; + if (linkedCount > 0) { + msg += ",回填本地草稿 " + linkedCount + " 条"; + } + return Result.OK(msg); + //update-end---author:GHT ---date:2026-06-04 for:【MESToDing审批配置】批量导入:优先回填同名本地草稿,避免重复记录----- + } + //update-end---author:GHT ---date:2026-06-03 for:【MESToDing审批配置】从钉钉同步审批模板列表----- + + //update-begin---author:GHT ---date:2026-06-03 for:【MESToDing审批配置】获取模板字段映射详情----- + @Operation(summary = "钉钉审批模板配置-获取模板字段映射详情(含钉钉表单Schema)") + @GetMapping(value = "/getTemplateDetail") + public Result> getTemplateDetail(@RequestParam(name = "id") String id) { + MesXslDingProcessTpl tpl = mesXslDingProcessTplService.getById(id); + if (tpl == null) { + return Result.error("未找到对应模板配置"); + } + + Map detail = new LinkedHashMap<>(); + detail.put("id", tpl.getId()); + detail.put("tplName", tpl.getTplName()); + detail.put("processCode", tpl.getProcessCode()); + detail.put("bizType", tpl.getBizType()); + detail.put("formFields", tpl.getFormFields()); + + // 从钉钉拉取表单 Schema,解析控件列表 + String accessToken = dingtalkService.getAccessToken(); + if (oConvertUtils.isEmpty(accessToken)) { + detail.put("schemaError", "AccessToken 获取失败,请检查钉钉应用配置"); + return Result.OK(detail); + } + try { + String url = "https://api.dingtalk.com/v1.0/workflow/forms/schemas/processCodes" + + "?processCode=" + tpl.getProcessCode(); + HttpClient httpClient = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(10)).build(); + HttpRequest httpReq = HttpRequest.newBuilder() + .uri(URI.create(url)) + .header("x-acs-dingtalk-access-token", accessToken) + .GET() + .timeout(Duration.ofSeconds(10)) + .build(); + String respBody = httpClient.send(httpReq, HttpResponse.BodyHandlers.ofString()).body(); + + JSONObject ddResp = JSONObject.parseObject(respBody); + if (ddResp.containsKey("code")) { + detail.put("schemaError", ddResp.getString("message") + " (code=" + ddResp.getString("code") + ")"); + return Result.OK(detail); + } + + // 兼容两种响应结构:直接对象 或 包裹在 result 字段中 + JSONObject root = ddResp.containsKey("result") + ? ddResp.getJSONObject("result") : ddResp; + + // 调试:输出 root 的所有 key 和 schemaContent 的类型 + Object schemaContentRaw = root.get("schemaContent"); + log.info("root keys={}, schemaContent type={}, value={}", + root.keySet(), + schemaContentRaw == null ? "null" : schemaContentRaw.getClass().getSimpleName(), + schemaContentRaw); + detail.put("_debug_rootKeys", root.keySet()); + detail.put("_debug_schemaContentType", + schemaContentRaw == null ? "null" : schemaContentRaw.getClass().getSimpleName()); + + // schemaContent 可能是 JSONObject 也可能是 JSON 字符串,兼容两种情况 + JSONObject schemaContent = null; + if (schemaContentRaw instanceof JSONObject) { + schemaContent = (JSONObject) schemaContentRaw; + } else if (schemaContentRaw instanceof String) { + schemaContent = JSONObject.parseObject((String) schemaContentRaw); + } + + List> dingFields = new ArrayList<>(); + if (schemaContent != null) { + // items 可能在 schemaContent 顶层,也可能嵌套在 form 下 + JSONArray items = schemaContent.getJSONArray("items"); + if (items == null) { + JSONObject form = schemaContent.getJSONObject("form"); + if (form != null) items = form.getJSONArray("items"); + } + if (items != null) { + for (int i = 0; i < items.size(); i++) { + Object itemObj = items.get(i); + JSONObject item = itemObj instanceof JSONObject + ? (JSONObject) itemObj + : JSONObject.parseObject(String.valueOf(itemObj)); + // props 同样兼容 JSONObject / String + Object propsRaw = item.get("props"); + JSONObject props = null; + if (propsRaw instanceof JSONObject) { + props = (JSONObject) propsRaw; + } else if (propsRaw instanceof String) { + props = JSONObject.parseObject((String) propsRaw); + } + String label = props != null ? props.getString("label") : item.getString("label"); + if (oConvertUtils.isEmpty(label)) continue; + Map field = new LinkedHashMap<>(); + field.put("label", label); + field.put("componentName", item.getString("componentName")); + field.put("id", props != null ? props.getString("id") : item.getString("id")); + Object req = props != null ? props.get("required") : null; + field.put("required", Boolean.TRUE.equals(req) || "true".equals(String.valueOf(req))); + + //update-begin---author:GHT ---date:2026-06-03 for:【MESToDing审批配置】getTemplateDetail补充select选项供发起表单渲染----------- + // DDSelectField / DDMultiSelectField:解析选项列表,供前端发起表单渲染下拉选项 + String cName = item.getString("componentName"); + if ("DDSelectField".equals(cName) || "DDMultiSelectField".equals(cName)) { + JSONArray opts = props != null ? props.getJSONArray("options") : null; + if (opts != null && !opts.isEmpty()) { + List> optList = new ArrayList<>(); + for (int oi = 0; oi < opts.size(); oi++) { + Object optRaw = opts.get(oi); + JSONObject opt = optRaw instanceof JSONObject + ? (JSONObject) optRaw : JSONObject.parseObject(String.valueOf(optRaw)); + Map o = new LinkedHashMap<>(); + o.put("key", opt.getString("key")); + o.put("value", opt.getString("value")); + optList.add(o); + } + field.put("options", optList); + } + } + //update-end---author:GHT ---date:2026-06-03 for:【MESToDing审批配置】getTemplateDetail补充select选项供发起表单渲染----------- + + // TableField:解析子控件 children + if ("TableField".equals(item.getString("componentName"))) { + JSONArray childrenArr = item.getJSONArray("children"); + List> childFields = new ArrayList<>(); + if (childrenArr != null) { + for (int ci = 0; ci < childrenArr.size(); ci++) { + Object childObj = childrenArr.get(ci); + JSONObject child = childObj instanceof JSONObject + ? (JSONObject) childObj + : JSONObject.parseObject(String.valueOf(childObj)); + Object childPropsRaw = child.get("props"); + JSONObject childProps = null; + if (childPropsRaw instanceof JSONObject) { + childProps = (JSONObject) childPropsRaw; + } else if (childPropsRaw instanceof String) { + childProps = JSONObject.parseObject((String) childPropsRaw); + } + String childLabel = childProps != null ? childProps.getString("label") : child.getString("label"); + if (oConvertUtils.isEmpty(childLabel)) continue; + Map childField = new LinkedHashMap<>(); + childField.put("label", childLabel); + childField.put("componentName", child.getString("componentName")); + Object childReq = childProps != null ? childProps.get("required") : null; + childField.put("required", Boolean.TRUE.equals(childReq) || "true".equals(String.valueOf(childReq))); + childFields.add(childField); + } + } + field.put("children", childFields); + } + + dingFields.add(field); + } + } + } + detail.put("dingFields", dingFields); + detail.put("dingFieldsCount", dingFields.size()); + } catch (Exception e) { + log.warn("钉钉 Schema 接口异常 processCode={}: {}", tpl.getProcessCode(), e.getMessage()); + detail.put("schemaError", "接口异常: " + e.getMessage()); + } + return Result.OK(detail); + } + + @Operation(summary = "钉钉审批模板配置-保存字段映射") + @PostMapping(value = "/saveFieldMapping") + public Result saveFieldMapping(@RequestBody Map body) { + String id = String.valueOf(body.getOrDefault("id", "")); + String formFields = String.valueOf(body.getOrDefault("formFields", "")); + if (oConvertUtils.isEmpty(id)) { + return Result.error("缺少模板ID"); + } + MesXslDingProcessTpl tpl = new MesXslDingProcessTpl(); + tpl.setId(id); + tpl.setFormFields(formFields); + mesXslDingProcessTplService.updateById(tpl); + return Result.OK("字段映射保存成功"); + } + //update-end---author:GHT ---date:2026-06-03 for:【MESToDing审批配置】获取模板字段映射详情----- + + //update-begin---author:GHT ---date:2026-06-03 for:【MESToDing审批配置】创建/更新钉钉审批模板----- + @Operation(summary = "钉钉审批模板配置-创建钉钉审批模板(POST不带processCode=新建)") + @RequiresPermissions("xslmes:mes_xsl_ding_process_tpl:edit") + @PostMapping(value = "/createDingTemplate") + public Result> createDingTemplate(@RequestBody Map body) { + String id = String.valueOf(body.getOrDefault("id", "")); + MesXslDingProcessTpl tpl = mesXslDingProcessTplService.getById(id); + if (tpl == null) return Result.error("未找到对应模板配置"); + + String accessToken = dingtalkService.getAccessToken(); + if (oConvertUtils.isEmpty(accessToken)) return Result.error("AccessToken 获取失败"); + + List components = buildFormComponentList(tpl.getFormFields()); + if (components.isEmpty()) return Result.error("请先在字段映射中配置至少一个字段"); + + // 不带 processCode → 钉钉新建模板 + DingFormCreateRequest req = new DingFormCreateRequest() + .setName(tpl.getTplName()) + .setDescription(oConvertUtils.isNotEmpty(tpl.getRemark()) ? tpl.getRemark() : "") + .setFormComponents(components); + + try { + String reqJson = JSON.toJSONString(req); + log.info("【钉钉创建模板】请求体: {}", reqJson); + String respBody = callDingApi("POST", "https://api.dingtalk.com/v1.0/workflow/forms", accessToken, reqJson); + JSONObject resp = JSONObject.parseObject(respBody); + log.info("【钉钉创建模板】响应: {}", respBody); + + if (resp.containsKey("code")) { + return Result.error("钉钉返回错误: " + resp.getString("message") + " (code=" + resp.getString("code") + ")"); + } + + // 钉钉 v1.0 接口 processCode 在 result 节点内,非响应根字段 + String processCode = extractProcessCodeFromDingFormResp(resp); + if (oConvertUtils.isEmpty(processCode)) { + log.warn("【钉钉创建模板】响应中未解析到 processCode,原始响应: {}", respBody); + return Result.error("钉钉模板已提交,但未返回 processCode,请从钉钉同步回填或查看后台日志"); + } + MesXslDingProcessTpl update = new MesXslDingProcessTpl(); + update.setId(tpl.getId()); + update.setProcessCode(processCode); + mesXslDingProcessTplService.updateById(update); + + Map result = new LinkedHashMap<>(); + result.put("processCode", processCode); + result.put("rawResponse", resp); + return Result.OK("钉钉审批模板创建成功", result); + } catch (Exception e) { + log.error("创建钉钉模板异常", e); + return Result.error("请求异常: " + e.getMessage()); + } + } + + @Operation(summary = "钉钉审批模板配置-更新钉钉审批模板(POST带processCode=更新已有)") + @RequiresPermissions("xslmes:mes_xsl_ding_process_tpl:edit") + @PostMapping(value = "/updateDingTemplate") + public Result> updateDingTemplate(@RequestBody Map body) { + String id = String.valueOf(body.getOrDefault("id", "")); + MesXslDingProcessTpl tpl = mesXslDingProcessTplService.getById(id); + if (tpl == null) return Result.error("未找到对应模板配置"); + if (oConvertUtils.isEmpty(tpl.getProcessCode())) return Result.error("该记录尚无 processCode,请先创建模板"); + + String accessToken = dingtalkService.getAccessToken(); + if (oConvertUtils.isEmpty(accessToken)) return Result.error("AccessToken 获取失败"); + + List components = buildFormComponentList(tpl.getFormFields()); + if (components.isEmpty()) return Result.error("请先在字段映射中配置至少一个字段"); + + // 带 processCode → 钉钉更新已有模板(官方文档:POST同一接口,有processCode=更新) + DingFormUpdateRequest req = new DingFormUpdateRequest() + .setProcessCode(tpl.getProcessCode()) + .setName(tpl.getTplName()) + .setDescription(oConvertUtils.isNotEmpty(tpl.getRemark()) ? tpl.getRemark() : "") + .setFormComponents(components); + + try { + String reqJson = JSON.toJSONString(req); + log.info("【钉钉更新模板】processCode={} 请求体: {}", tpl.getProcessCode(), reqJson); + String respBody = callDingApi("POST", "https://api.dingtalk.com/v1.0/workflow/forms", accessToken, reqJson); + JSONObject resp = JSONObject.parseObject(respBody); + log.info("【钉钉更新模板】响应: {}", respBody); + + if (resp.containsKey("code")) { + return Result.error("钉钉返回错误: " + resp.getString("message") + " (code=" + resp.getString("code") + ")"); + } + + Map result = new LinkedHashMap<>(); + result.put("processCode", tpl.getProcessCode()); + result.put("rawResponse", resp); + return Result.OK("钉钉审批模板更新成功", result); + } catch (Exception e) { + log.error("更新钉钉模板异常", e); + return Result.error("请求异常: " + e.getMessage()); + } + } + //update-end---author:GHT ---date:2026-06-03 for:【MESToDing审批配置】创建/更新钉钉审批模板----- + + //update-begin---author:GHT ---date:2026-06-03 for:【MESToDing审批配置】buildFormComponentList支持完整控件属性----------- + /** + * 解析 formFields JSON → 构建钉钉表单控件 DTO 列表。 + * 支持两种格式: + * 数组格式(新):[{"componentType":"TextField","label":"PS编码","mesField":"psCode","required":false,...}] + * 对象格式(旧):{"PS编码":"psCode"} + */ + private List buildFormComponentList(String formFieldsJson) { + List components = new ArrayList<>(); + if (oConvertUtils.isEmpty(formFieldsJson)) return components; + try { + String json = formFieldsJson.trim(); + if (json.startsWith("[")) { + JSONArray arr = JSONArray.parseArray(json); + for (int i = 0; i < arr.size(); i++) { + JSONObject item = arr.getJSONObject(i); + String label = item.getString("label"); + if (oConvertUtils.isEmpty(label)) continue; + String componentType = oConvertUtils.isNotEmpty(item.getString("componentType")) + ? item.getString("componentType") : inferComponentType(label); + boolean required = Boolean.TRUE.equals(item.getBoolean("required")); + + // componentId:优先用存储值,否则自动生成 + String componentId = oConvertUtils.isNotEmpty(item.getString("componentId")) + ? item.getString("componentId") + : componentType + "-mes-" + (i + 1); + + // placeholder:优先用存储值 + String placeholder = oConvertUtils.isNotEmpty(item.getString("placeholder")) + ? item.getString("placeholder") : "请输入" + label; + + DingFormComponentProps props = new DingFormComponentProps() + .setComponentId(componentId) + .setLabel(label) + .setPlaceholder(placeholder) + .setRequired(required); + + // 日期格式(先确定 format,再根据 format 决定 unit) + String formatVal = item.getString("format"); + if ("DDDateField".equals(componentType) || "DDDateRangeField".equals(componentType)) { + if (!oConvertUtils.isNotEmpty(formatVal)) { + formatVal = "DDDateRangeField".equals(componentType) ? "yyyy-MM-dd HH:mm" : "yyyy-MM-dd"; + } + props.setFormat(formatVal); + } else if (oConvertUtils.isNotEmpty(formatVal)) { + props.setFormat(formatVal); + } + + // 单位:DDDateField/DDDateRangeField 必须传 unit + // 用户自定义白名单值优先;否则按 format 自动推断 + String unitVal = item.getString("unit"); + if ("DDDateField".equals(componentType) || "DDDateRangeField".equals(componentType)) { + if ("小时".equals(unitVal) || "天".equals(unitVal) || "半天".equals(unitVal)) { + props.setUnit(unitVal); + } else { + // format 含时分 → 小时;纯日期 → 天 + props.setUnit((formatVal != null && formatVal.contains("HH")) ? "小时" : "天"); + } + } else if (oConvertUtils.isNotEmpty(unitVal)) { + props.setUnit(unitVal); + } + // 单选/多选 - 选项列表 + if ("DDSelectField".equals(componentType) || "DDMultiSelectField".equals(componentType)) { + JSONArray optArr = item.getJSONArray("options"); + if (optArr != null && !optArr.isEmpty()) { + List optList = new ArrayList<>(); + for (int oi = 0; oi < optArr.size(); oi++) { + JSONObject opt = optArr.getJSONObject(oi); + optList.add(new DingFormComponentProps.SelectOption() + .setKey(oConvertUtils.isNotEmpty(opt.getString("key")) ? opt.getString("key") : "option" + (oi + 1)) + .setValue(oConvertUtils.isNotEmpty(opt.getString("value")) ? opt.getString("value") : "选项" + (oi + 1))); + } + props.setOptions(optList); + } + } + // 金额大写 + if ("MoneyField".equals(componentType)) { + props.setUpper(Boolean.TRUE.equals(item.getBoolean("upper")) ? "1" : "0"); + } + // 说明文字 + if ("TextNote".equals(componentType)) { + if (oConvertUtils.isNotEmpty(item.getString("content"))) { + props.setContent(item.getString("content")); + } + props.setRequired(false); + props.setPrint("0"); + } + // 电话 + if ("PhoneField".equals(componentType)) { + props.setMode("phone"); + } + // 联系人 + if ("InnerContactField".equals(componentType)) { + props.setChoice(oConvertUtils.isNotEmpty(item.getString("choice")) ? item.getString("choice") : "1"); + } + // 部门 + if ("DepartmentField".equals(componentType)) { + props.setMultiple(Boolean.TRUE.equals(item.getBoolean("multiple"))); + } + // 省市区 + if ("AddressField".equals(componentType)) { + props.setAddressModel(oConvertUtils.isNotEmpty(item.getString("addressModel")) + ? item.getString("addressModel") : "city"); + } + // 评分 + if ("StarRatingField".equals(componentType) && item.getInteger("limit") != null) { + props.setLimit(item.getInteger("limit")); + } + + // TableField — 构建子控件列表 + if ("TableField".equals(componentType)) { + String tableViewMode = oConvertUtils.isNotEmpty(item.getString("tableViewMode")) + ? item.getString("tableViewMode") : "table"; + Boolean verticalPrint = Boolean.TRUE.equals(item.getBoolean("verticalPrint")); + props.setTableViewMode(tableViewMode) + .setVerticalPrint(verticalPrint) + .setUpper("0"); + + JSONArray childrenArr = item.getJSONArray("children"); + List childComponents = new ArrayList<>(); + List statFields = new ArrayList<>(); + if (childrenArr != null) { + for (int ci = 0; ci < childrenArr.size(); ci++) { + JSONObject child = childrenArr.getJSONObject(ci); + String childLabel = child.getString("label"); + if (oConvertUtils.isEmpty(childLabel)) continue; + String childType = oConvertUtils.isNotEmpty(child.getString("componentType")) + ? child.getString("componentType") : "TextField"; + String childId = childType + "-child-" + (ci + 1); + DingFormComponentProps childProps = new DingFormComponentProps() + .setComponentId(childId) + .setLabel(childLabel) + .setPlaceholder("请输入") + .setRequired(Boolean.TRUE.equals(child.getBoolean("required"))); + if (("NumberField".equals(childType) || "MoneyField".equals(childType)) + && oConvertUtils.isNotEmpty(child.getString("unit"))) { + childProps.setUnit(child.getString("unit")); + } + if ("DDDateField".equals(childType)) { + childProps.setFormat("yyyy-MM-dd").setUnit("天"); + } + childComponents.add(new DingFormComponent().setComponentType(childType).setProps(childProps)); + // NumberField / MoneyField 子控件加入汇总统计 + if ("NumberField".equals(childType) || "MoneyField".equals(childType)) { + statFields.add(new DingFormComponentProps.StatField().setComponentId(childId).setLabel(childLabel)); + } + } + } + // 钉钉要求 TableField 必须有子控件 + if (childComponents.isEmpty()) { + log.warn("TableField [{}] 无子控件,已跳过(钉钉要求至少一个子控件)", label); + continue; + } + if (!statFields.isEmpty()) props.setStatField(statFields); + components.add(new DingFormComponent().setComponentType(componentType).setProps(props).setChildren(childComponents)); + continue; + } + + // RelateField — 关联审批单模板列表 + if ("RelateField".equals(componentType)) { + JSONArray tplArr = item.getJSONArray("availableTemplates"); + if (tplArr != null && !tplArr.isEmpty()) { + List tpls = new ArrayList<>(); + for (int ti = 0; ti < tplArr.size(); ti++) { + JSONObject t = tplArr.getJSONObject(ti); + if (oConvertUtils.isNotEmpty(t.getString("processCode"))) { + tpls.add(new DingFormComponentProps.AvailableTemplate() + .setName(oConvertUtils.isNotEmpty(t.getString("name")) ? t.getString("name") : "关联模板") + .setProcessCode(t.getString("processCode"))); + } + } + if (!tpls.isEmpty()) props.setAvailableTemplates(tpls); + } + } + + components.add(new DingFormComponent().setComponentType(componentType).setProps(props)); + } + } else { + // 旧:对象格式 {"label":"mesField"},兼容处理 + JSONObject fields = JSONObject.parseObject(json); + int idx = 1; + for (String label : fields.keySet()) { + String componentType = inferComponentType(label); + DingFormComponentProps props = new DingFormComponentProps() + .setComponentId(componentType + "-mes-" + idx) + .setLabel(label) + .setPlaceholder("请输入" + label) + .setRequired(false); + if ("DDDateField".equals(componentType)) { + props.setFormat("yyyy-MM-dd").setUnit("天"); + } else if ("DDDateRangeField".equals(componentType)) { + props.setFormat("yyyy-MM-dd HH:mm").setUnit("小时"); + } + components.add(new DingFormComponent().setComponentType(componentType).setProps(props)); + idx++; + } + } + } catch (Exception e) { + log.warn("解析 formFields 失败: {}", e.getMessage()); + } + return components; + } + //update-end---author:GHT ---date:2026-06-03 for:【MESToDing审批配置】buildFormComponentList支持完整控件属性----------- + + /** 按控件标题关键词推断钉钉控件类型 */ + private String inferComponentType(String label) { + if (label == null) return "TextField"; + if (label.contains("日期") || label.contains("时间") || label.contains("Date")) return "DDDateField"; + if (label.contains("数量") || label.contains("金额") || label.contains("单价") + || label.contains("重量") || label.contains("数字")) return "NumberField"; + if (label.contains("图片") || label.contains("照片")) return "DDPhotoField"; + if (label.contains("附件") || label.contains("文件")) return "DDAttachment"; + if (label.contains("部门")) return "DepartmentField"; + if (label.contains("备注") || label.contains("说明") || label.contains("描述")) return "TextareaField"; + return "TextField"; + } + + /** 从「创建/更新审批表单」响应中解析 processCode(兼容根节点与 result 节点) */ + private String extractProcessCodeFromDingFormResp(JSONObject resp) { + if (resp == null) { + return null; + } + String code = resp.getString("processCode"); + if (oConvertUtils.isNotEmpty(code)) { + return code; + } + JSONObject result = resp.getJSONObject("result"); + if (result != null && oConvertUtils.isNotEmpty(result.getString("processCode"))) { + return result.getString("processCode"); + } + return null; + } + + /** 查找尚未绑定 processCode 的本地草稿(按模板名称) */ + private MesXslDingProcessTpl findDraftWithoutProcessCode(String tplName) { + if (oConvertUtils.isEmpty(tplName)) { + return null; + } + QueryWrapper qw = new QueryWrapper<>(); + qw.eq("tpl_name", tplName.trim()); + qw.and(w -> w.isNull("process_code").or().eq("process_code", "")); + qw.last("LIMIT 1"); + return mesXslDingProcessTplService.getOne(qw, false); + } + + /** 统一 HTTP 调用钉钉 v1.0 接口 */ + private String callDingApi(String method, String url, String accessToken, String jsonBody) throws Exception { + HttpClient httpClient = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(10)).build(); + HttpRequest.Builder builder = HttpRequest.newBuilder() + .uri(URI.create(url)) + .header("x-acs-dingtalk-access-token", accessToken) + .header("Content-Type", "application/json") + .timeout(Duration.ofSeconds(10)); + if ("PUT".equalsIgnoreCase(method)) { + builder.PUT(HttpRequest.BodyPublishers.ofString(jsonBody)); + } else { + builder.POST(HttpRequest.BodyPublishers.ofString(jsonBody)); + } + return httpClient.send(builder.build(), HttpResponse.BodyHandlers.ofString()).body(); + } + //update-end---author:GHT ---date:2026-06-03 for:【MESToDing审批配置】创建/更新钉钉审批模板----- + + //update-begin---author:GHT ---date:2026-06-04 for:【MESToDing审批配置】绑定MES审批流(审批人来源)----------- + @Operation(summary = "钉钉审批模板配置-绑定MES审批流") + @PostMapping(value = "/bindFlow") + public Result bindFlow(@RequestBody Map body) { + String id = String.valueOf(body.getOrDefault("id", "")); + String flowId = String.valueOf(body.getOrDefault("flowId", "")); + if (oConvertUtils.isEmpty(id)) { + return Result.error("缺少模板ID"); + } + MesXslDingProcessTpl update = new MesXslDingProcessTpl(); + update.setId(id); + update.setFlowId(oConvertUtils.isNotEmpty(flowId) ? flowId : null); + mesXslDingProcessTplService.updateById(update); + return Result.OK("绑定成功"); + } + //update-end---author:GHT ---date:2026-06-04 for:【MESToDing审批配置】绑定MES审批流(审批人来源)----------- + + //update-begin---author:GHT ---date:2026-06-04 for:【MESToDing审批配置】预览审批流各节点的审批人解析结果----------- + /** + * 预览指定审批流各审批节点的钉钉用户解析状态,供前端发起弹窗展示。 + * 返回:[{ nodeId, nodeName, approverType, multiMode, users:[{username,realname,dtUserId,resolved}], allResolved }] + */ + @Operation(summary = "钉钉审批模板配置-预览审批流审批人解析状态") + @GetMapping(value = "/previewFlowApprovers") + public Result>> previewFlowApprovers(@RequestParam("flowId") String flowId) { + MesXslApprovalFlow flow = approvalFlowService.getById(flowId); + if (flow == null) { + return Result.error("审批流不存在"); + } + if (oConvertUtils.isEmpty(flow.getFlowConfig())) { + return Result.OK(Collections.emptyList()); + } + + String accessToken = dingtalkService.getAccessToken(); + int tenantId = oConvertUtils.getInt(TenantContext.getTenant(), CommonConstant.TENANT_ID_DEFAULT_VALUE); + Map resolveCache = new HashMap<>(); + + JSONObject root = JSONObject.parseObject(flow.getFlowConfig()); + List approverNodes = new ArrayList<>(); + collectApproverNodes(root, approverNodes); + + Set visitedIds = new LinkedHashSet<>(); + List> result = new ArrayList<>(); + + for (JSONObject node : approverNodes) { + String nid = node.getString("id"); + if (nid != null && !visitedIds.add(nid)) continue; + JSONObject props = node.getJSONObject("props"); + if (props == null) continue; + + String approverType = props.getString("approverType"); + List> userInfos = new ArrayList<>(); + + if ("user".equals(approverType)) { + String userText = props.getString("userText"); + if (oConvertUtils.isNotEmpty(userText)) { + for (String uname : userText.split("[,,\\s]+")) { + uname = uname.trim(); + if (oConvertUtils.isEmpty(uname)) continue; + String dtId = resolveDtUserIdWithFallback(uname, accessToken, tenantId, resolveCache); + String realname = lookupRealname(uname); + Map u = new LinkedHashMap<>(); + u.put("username", uname); + u.put("realname", oConvertUtils.isNotEmpty(realname) ? realname : uname); + u.put("dtUserId", dtId); + u.put("resolved", oConvertUtils.isNotEmpty(dtId)); + userInfos.add(u); + } + } + } else if ("role".equals(approverType)) { + JSONArray roleList = props.getJSONArray("roleList"); + if (roleList != null && !roleList.isEmpty()) { + List roleIds = new ArrayList<>(); + for (int ri = 0; ri < roleList.size(); ri++) { + String rid = roleList.getString(ri); + if (oConvertUtils.isNotEmpty(rid)) roleIds.add(rid); + } + if (!roleIds.isEmpty()) { + String inClause = String.join(",", Collections.nCopies(roleIds.size(), "?")); + List> rows = jdbcTemplate.queryForList( + "SELECT u.username, u.realname FROM sys_user u" + + " INNER JOIN sys_user_role sur ON sur.user_id = u.id" + + " WHERE sur.role_id IN (" + inClause + ")" + + " AND (u.del_flag = 0 OR u.del_flag IS NULL)", + roleIds.toArray()); + for (Map row : rows) { + String uname = String.valueOf(row.get("username")); + String realname = String.valueOf(row.getOrDefault("realname", uname)); + String dtId = resolveDtUserIdWithFallback(uname, accessToken, tenantId, resolveCache); + Map u = new LinkedHashMap<>(); + u.put("username", uname); + u.put("realname", oConvertUtils.isNotEmpty(realname) ? realname : uname); + u.put("dtUserId", dtId); + u.put("resolved", oConvertUtils.isNotEmpty(dtId)); + userInfos.add(u); + } + } + } + } else if ("self".equals(approverType)) { + Map u = new LinkedHashMap<>(); + u.put("username", "self"); + u.put("realname", "发起人自己"); + u.put("dtUserId", "auto"); + u.put("resolved", true); + userInfos.add(u); + } else { + Map u = new LinkedHashMap<>(); + u.put("username", approverType); + u.put("realname", approverType.equals("leader") ? "主管" : "取单据字段"); + u.put("dtUserId", null); + u.put("resolved", false); + u.put("unsupported", true); + userInfos.add(u); + } + + boolean allResolved = userInfos.stream().allMatch(u -> Boolean.TRUE.equals(u.get("resolved"))); + Map nodeMap = new LinkedHashMap<>(); + nodeMap.put("nodeId", nid); + nodeMap.put("nodeName", node.getString("name")); + nodeMap.put("nodeType", "approver"); + nodeMap.put("approverType", approverType); + nodeMap.put("multiMode", props.getString("multiMode")); + nodeMap.put("users", userInfos); + nodeMap.put("allResolved", allResolved); + result.add(nodeMap); + } + + // 追加 CC 节点预览(nodeType=cc,不需要补充手机号,仅展示解析状态) + List ccNodes = new ArrayList<>(); + collectCcNodes(root, ccNodes); + Set ccVisited = new LinkedHashSet<>(); + for (JSONObject node : ccNodes) { + String nid = node.getString("id"); + if (nid != null && !ccVisited.add(nid)) continue; + JSONObject props = node.getJSONObject("props"); + if (props == null) continue; + String ccType = oConvertUtils.getString(props.getString("ccType"), "user"); + + List> userInfos = new ArrayList<>(); + if ("user".equals(ccType)) { + String userText = props.getString("userText"); + if (oConvertUtils.isNotEmpty(userText)) { + for (String uname : userText.split("[,,\\s]+")) { + uname = uname.trim(); + if (oConvertUtils.isEmpty(uname)) continue; + String dtId = resolveDtUserIdWithFallback(uname, accessToken, tenantId, resolveCache); + String realname = lookupRealname(uname); + Map u = new LinkedHashMap<>(); + u.put("username", uname); + u.put("realname", oConvertUtils.isNotEmpty(realname) ? realname : uname); + u.put("dtUserId", dtId); + u.put("resolved", oConvertUtils.isNotEmpty(dtId)); + userInfos.add(u); + } + } + } else if ("role".equals(ccType)) { + JSONArray roleList = props.getJSONArray("roleList"); + if (roleList != null && !roleList.isEmpty()) { + List roleIds = new ArrayList<>(); + for (int ri = 0; ri < roleList.size(); ri++) { + String rid = roleList.getString(ri); + if (oConvertUtils.isNotEmpty(rid)) roleIds.add(rid); + } + if (!roleIds.isEmpty()) { + String inClause = String.join(",", Collections.nCopies(roleIds.size(), "?")); + List> rows = jdbcTemplate.queryForList( + "SELECT u.username, u.realname FROM sys_user u" + + " INNER JOIN sys_user_role sur ON sur.user_id = u.id" + + " WHERE sur.role_id IN (" + inClause + ")" + + " AND (u.del_flag = 0 OR u.del_flag IS NULL)", + roleIds.toArray()); + for (Map row : rows) { + String uname = String.valueOf(row.get("username")); + String realname = String.valueOf(row.getOrDefault("realname", uname)); + String dtId = resolveDtUserIdWithFallback(uname, accessToken, tenantId, resolveCache); + Map u = new LinkedHashMap<>(); + u.put("username", uname); + u.put("realname", oConvertUtils.isNotEmpty(realname) ? realname : uname); + u.put("dtUserId", dtId); + u.put("resolved", oConvertUtils.isNotEmpty(dtId)); + userInfos.add(u); + } + } + } + } else { + // field 类型无法静态解析 + Map u = new LinkedHashMap<>(); + u.put("username", props.getString("fieldName")); + u.put("realname", "取单据字段:" + oConvertUtils.getString(props.getString("fieldLabel"), props.getString("fieldName"))); + u.put("resolved", false); + u.put("unsupported", true); + userInfos.add(u); + } + + boolean allResolved = userInfos.stream().allMatch(u -> Boolean.TRUE.equals(u.get("resolved"))); + Map nodeMap = new LinkedHashMap<>(); + nodeMap.put("nodeId", nid); + nodeMap.put("nodeName", node.getString("name")); + nodeMap.put("nodeType", "cc"); + nodeMap.put("ccType", ccType); + nodeMap.put("users", userInfos); + nodeMap.put("allResolved", allResolved); + result.add(nodeMap); + } + + return Result.OK(result); + } + + /** 从 sys_user 查真实姓名 */ + private String lookupRealname(String username) { + try { + List names = jdbcTemplate.queryForList( + "SELECT realname FROM sys_user WHERE username = ? AND (del_flag = 0 OR del_flag IS NULL) LIMIT 1", + String.class, username); + return names.isEmpty() ? null : names.get(0); + } catch (Exception e) { + return null; + } + } + //update-end---author:GHT ---date:2026-06-04 for:【MESToDing审批配置】预览审批流各节点的审批人解析结果----------- + + //update-begin---author:GHT ---date:2026-06-03 for:【MESToDing审批配置】手动填表发起钉钉审批----------- + //update-begin---author:GHT ---date:2026-06-04 for:【MESToDing审批配置】审批人改为从绑定的MES审批流解析----------- + @Operation(summary = "钉钉审批模板配置-手动填表发起钉钉审批") + @PostMapping(value = "/launchApproval") + public Result> launchApproval(@RequestBody Map body) { + String id = String.valueOf(body.getOrDefault("id", "")); + Object formValuesObj = body.get("formValues"); + // flowId 优先来自请求,否则从模板记录取 + String flowId = String.valueOf(body.getOrDefault("flowId", "")); + + MesXslDingProcessTpl tpl = mesXslDingProcessTplService.getById(id); + if (tpl == null) { + return Result.error("未找到对应模板配置"); + } + if (oConvertUtils.isEmpty(tpl.getProcessCode())) { + return Result.error("该模板尚无 processCode,请先在钉钉管理后台创建审批模板"); + } + + // 若请求中未传 flowId,尝试从模板自身已绑定的 flowId 取 + if (oConvertUtils.isEmpty(flowId) && oConvertUtils.isNotEmpty(tpl.getFlowId())) { + flowId = tpl.getFlowId(); + } + if (oConvertUtils.isEmpty(flowId)) { + return Result.error("请先在「审批流配置」页签中选择或新建一个审批流"); + } + + // 若请求中的 flowId 与模板当前绑定不同,更新绑定关系 + if (!flowId.equals(tpl.getFlowId())) { + MesXslDingProcessTpl update = new MesXslDingProcessTpl(); + update.setId(tpl.getId()); + update.setFlowId(flowId); + mesXslDingProcessTplService.updateById(update); + } + + // 加载审批流定义 + MesXslApprovalFlow approvalFlow = approvalFlowService.getById(flowId); + if (approvalFlow == null) { + return Result.error("绑定的审批流不存在,请重新选择"); + } + if (oConvertUtils.isEmpty(approvalFlow.getFlowConfig())) { + return Result.error("审批流「" + approvalFlow.getFlowName() + "」尚未设计节点,请先完成流程设计后再发起"); + } + + String accessToken = dingtalkService.getAccessToken(); + if (oConvertUtils.isEmpty(accessToken)) { + return Result.error("钉钉 AccessToken 获取失败,请检查[系统配置-第三方应用]中的钉钉配置"); + } + + // ① 获取发起人 dtUserId(当前登录用户) + LoginUser loginUser = (LoginUser) SecurityUtils.getSubject().getPrincipal(); + int tenantId = oConvertUtils.getInt(TenantContext.getTenant(), CommonConstant.TENANT_ID_DEFAULT_VALUE); + String originatorDtUserId = resolveDtUserId(loginUser.getUsername(), loginUser.getPhone(), accessToken, tenantId); + if (oConvertUtils.isEmpty(originatorDtUserId)) { + return Result.error("未能获取当前用户的钉钉 userId,请先完成钉钉账号绑定或确认手机号已在企业钉钉注册"); + } + + // ② 解析前端传入的 per-node 手机号补充(nodeId → phones) + // 格式:approverOverrides: [{nodeId:"xxx", phones:"18600001111,18600002222"}, ...] + Map> nodePhoneOverrides = new HashMap<>(); + Object overridesObj = body.get("approverOverrides"); + if (overridesObj instanceof List) { + for (Object item : (List) overridesObj) { + if (!(item instanceof Map)) continue; + Map m = (Map) item; + String nid = m.get("nodeId") == null ? "" : String.valueOf(m.get("nodeId")).trim(); + String phones = m.get("phones") == null ? "" : String.valueOf(m.get("phones")).trim(); + if (oConvertUtils.isEmpty(nid) || oConvertUtils.isEmpty(phones)) continue; + List phoneList = new ArrayList<>(); + for (String p : phones.split("[,,\\s]+")) { + p = p.trim(); + if (oConvertUtils.isNotEmpty(p)) phoneList.add(p); + } + if (!phoneList.isEmpty()) nodePhoneOverrides.put(nid, phoneList); + } + } + + // ③ 从审批流设计解析审批节点 → 构建 DingTalk approvers + ccList(共享缓存,避免重复 HTTP 查询) + Map dtIdCache = new HashMap<>(); + JSONArray approvers = buildApproversFromFlowConfig( + approvalFlow.getFlowConfig(), originatorDtUserId, accessToken, tenantId, nodePhoneOverrides, dtIdCache); + if (approvers.isEmpty()) { + return Result.error("审批流「" + approvalFlow.getFlowName() + "」中未找到可用的审批人," + + "请检查流程设计中的审批人节点配置,或在「审批流配置」页签中补充审批人手机号"); + } + // 解析抄送人节点 → ccList(与审批节点共享 dtIdCache 和 nodePhoneOverrides) + List ccList = buildCcListFromFlowConfig( + approvalFlow.getFlowConfig(), accessToken, tenantId, dtIdCache, nodePhoneOverrides); + + // ④ 构建表单字段值 + List> formComponentValues = new ArrayList<>(); + if (formValuesObj instanceof List) { + for (Object item : (List) formValuesObj) { + if (!(item instanceof Map)) { + continue; + } + Map m = (Map) item; + Object nameRaw = m.get("name"); + String name = nameRaw == null ? "" : String.valueOf(nameRaw); + Object valueRaw = m.get("value"); + String value = valueRaw == null ? "" : String.valueOf(valueRaw); + if (oConvertUtils.isEmpty(name)) { + continue; + } + Map fv = new LinkedHashMap<>(); + fv.put("name", name); + fv.put("value", value); + formComponentValues.add(fv); + } + } + + // ⑤ 查询发起人在钉钉的所属部门 ID + long originatorDeptId = resolveUserDeptId(originatorDtUserId, accessToken); + + //update-begin---author:GHT ---date:20260604 for:【QH-MES审批台账】钉钉发起前统一门禁校验----- + String bizTable = firstNonEmpty(str(body.get("bizTable")), approvalFlow.getBizTable()); + String bizDataId = str(body.get("bizDataId")); + String bizTitle = str(body.get("bizTitle")); + String bizCode = str(body.get("bizCode")); + if (oConvertUtils.isNotEmpty(bizTable) && oConvertUtils.isNotEmpty(bizDataId)) { + try { + approvalGateService.assertCanLaunch(bizTable, bizDataId, tenantId); + } catch (IllegalStateException e) { + return Result.error(e.getMessage()); + } + } + //update-end---author:GHT ---date:20260604 for:【QH-MES审批台账】钉钉发起前统一门禁校验----- + + // ⑥ 组装钉钉发起审批请求体 + JSONObject reqBody = new JSONObject(); + reqBody.put("processCode", tpl.getProcessCode()); + reqBody.put("originatorUserId", originatorDtUserId); + reqBody.put("deptId", originatorDeptId); + reqBody.put("formComponentValues", formComponentValues); + reqBody.put("approvers", approvers); + //update-begin---author:GHT ---date:2026-06-04 for:【MESToDing审批配置】抄送人节点映射钉钉ccList----------- + if (!ccList.isEmpty()) { + reqBody.put("ccList", ccList); + // 根据抄送节点在流程中的相对位置动态确定钉钉 ccPosition + String ccPosition = determineCcPosition(approvalFlow.getFlowConfig()); + reqBody.put("ccPosition", ccPosition); + log.info("【钉钉发起审批】ccList共{}人 ccPosition={}", ccList.size(), ccPosition); + } + //update-end---author:GHT ---date:2026-06-04 for:【MESToDing审批配置】抄送人节点映射钉钉ccList----------- + + try { + String reqJson = reqBody.toJSONString(); + log.info("【钉钉发起审批】processCode={} flowId={}\n请求体(formComponentValues共{}项, approvers共{}步, ccList共{}人):\n{}", + tpl.getProcessCode(), flowId, + formComponentValues.size(), approvers.size(), ccList.size(), + reqJson); + String respBody = callDingApi("POST", "https://api.dingtalk.com/v1.0/workflow/processInstances", accessToken, reqJson); + JSONObject resp = JSONObject.parseObject(respBody); + log.info("【钉钉发起审批】响应: {}", respBody); + + if (resp.containsKey("code")) { + return Result.error("钉钉返回错误: " + resp.getString("message") + " (code=" + resp.getString("code") + ")"); + } + + String dingInstanceId = resp.getString("instanceId"); + //update-begin---author:GHT ---date:20260604 for:【QH-MES审批台账】钉钉发起成功后写入审批台账----- + if (oConvertUtils.isNotEmpty(bizTable) && oConvertUtils.isNotEmpty(bizDataId)) { + approvalGateService.createRunningRecord( + approvalGateService.buildDingDraft(bizTable, approvalFlow.getBizTableName(), bizCode, bizDataId, + bizTitle, flowId, approvalFlow.getFlowName(), tpl.getId(), tpl.getTplName(), + dingInstanceId, loginUser, tenantId)); + } + //update-end---author:GHT ---date:20260604 for:【QH-MES审批台账】钉钉发起成功后写入审批台账----- + + Map result = new LinkedHashMap<>(); + result.put("instanceId", dingInstanceId); + result.put("tplName", tpl.getTplName()); + return Result.OK("审批发起成功!审批人将在钉钉「待我审批」中收到任务", result); + } catch (Exception e) { + log.error("发起钉钉审批异常 processCode={}", tpl.getProcessCode(), e); + return Result.error("请求钉钉接口异常: " + e.getMessage()); + } + } + //update-end---author:GHT ---date:2026-06-04 for:【MESToDing审批配置】审批人改为从绑定的MES审批流解析----------- + + //update-begin---author:GHT ---date:2026-06-04 for:【MESToDing审批配置】审批流解析审批人-兜底用sys_user手机号查钉钉-nodePhone覆盖----------- + /** + * 从审批流 flowConfig JSON 解析所有审批人节点,构建 DingTalk approvers 数组。 + * 支持 approverType: user/self/role;leader/field 跳过。 + * 解析顺序:① sys_third_account → ② sys_user.phone → ③ nodePhoneOverrides 前端手动补充。 + * dtIdCache: 本次调用级缓存,由调用方传入,与 buildCcListFromFlowConfig 共享,避免重复查询。 + */ + private JSONArray buildApproversFromFlowConfig(String flowConfig, String originatorDtUserId, + String accessToken, int tenantId, + Map> nodePhoneOverrides, + Map dtIdCache) { + JSONArray approvers = new JSONArray(); + try { + JSONObject root = JSONObject.parseObject(flowConfig); + List approverNodes = new ArrayList<>(); + collectApproverNodes(root, approverNodes); + + Set visitedNodeIds = new LinkedHashSet<>(); + List dedupedNodes = new ArrayList<>(); + for (JSONObject n : approverNodes) { + String nid = n.getString("id"); + if (nid == null || visitedNodeIds.add(nid)) dedupedNodes.add(n); + } + + for (JSONObject node : dedupedNodes) { + JSONObject props = node.getJSONObject("props"); + if (props == null) continue; + String approverType = props.getString("approverType"); + String multiMode = props.getString("multiMode"); + // 映射 multiMode → DingTalk actionType + // none(单人) → NONE, or(或签) → OR, 其余(and/sequence) → AND + String actionType; + boolean isSingleMode = "none".equals(multiMode); + if ("or".equals(multiMode)) { + actionType = "OR"; + } else if (isSingleMode) { + actionType = "NONE"; + } else { + actionType = "AND"; + } + String nodeId = node.getString("id"); + + List dtUserIds = new ArrayList<>(); + + if ("user".equals(approverType)) { + String userText = props.getString("userText"); + if (oConvertUtils.isNotEmpty(userText)) { + for (String username : userText.split("[,,\\s]+")) { + username = username.trim(); + if (oConvertUtils.isEmpty(username)) continue; + String dtId = resolveDtUserIdWithFallback(username, accessToken, tenantId, dtIdCache); + if (oConvertUtils.isNotEmpty(dtId)) { + dtUserIds.add(dtId); + } else { + log.warn("审批流节点用户 [{}] 未能自动解析到钉钉 userId", username); + } + } + } + } else if ("self".equals(approverType)) { + dtUserIds.add(originatorDtUserId); + } else if ("role".equals(approverType)) { + JSONArray roleList = props.getJSONArray("roleList"); + if (roleList != null && !roleList.isEmpty()) { + List roleIds = new ArrayList<>(); + for (int ri = 0; ri < roleList.size(); ri++) { + String rid = roleList.getString(ri); + if (oConvertUtils.isNotEmpty(rid)) roleIds.add(rid); + } + if (!roleIds.isEmpty()) { + String inClause = String.join(",", Collections.nCopies(roleIds.size(), "?")); + List usernames = jdbcTemplate.queryForList( + "SELECT DISTINCT u.username FROM sys_user u" + + " INNER JOIN sys_user_role sur ON sur.user_id = u.id" + + " WHERE sur.role_id IN (" + inClause + ")" + + " AND (u.del_flag = 0 OR u.del_flag IS NULL)", + String.class, roleIds.toArray()); + for (String username : usernames) { + String dtId = resolveDtUserIdWithFallback(username, accessToken, tenantId, dtIdCache); + if (oConvertUtils.isNotEmpty(dtId)) { + dtUserIds.add(dtId); + } else { + log.warn("审批流角色成员 [{}] 未能自动解析到钉钉 userId", username); + } + } + } + } + } else { + log.info("approverType={} 不支持钉钉自动解析,已跳过(节点={})", approverType, node.getString("name")); + continue; + } + + // 用前端手动补充的手机号填补自动解析的缺口 + List manualPhones = nodePhoneOverrides.getOrDefault(nodeId, Collections.emptyList()); + for (String phone : manualPhones) { + if (oConvertUtils.isEmpty(phone)) continue; + Response resp = JdtUserAPI.getUseridByMobile(phone, accessToken); + if (resp.isSuccess() && oConvertUtils.isNotEmpty(resp.getResult())) { + dtUserIds.add(resp.getResult()); + log.info("节点 [{}] 手动补充手机号 {} 解析成功", nodeId, phone); + } else { + log.warn("节点 [{}] 手动补充手机号 {} 未找到钉钉用户,已跳过", nodeId, phone); + } + } + + if (!dtUserIds.isEmpty()) { + List unique = new ArrayList<>(new LinkedHashSet<>(dtUserIds)); + // NONE(单人审批):DingTalk 要求仅传一个 userId + if (isSingleMode && unique.size() > 1) { + log.warn("节点 [{}] 为单人审批(NONE),解析到 {} 位审批人,自动保留第一位 {}", + nodeId, unique.size(), unique.get(0)); + unique = unique.subList(0, 1); + } + JSONObject step = new JSONObject(); + step.put("actionType", actionType); + step.put("userIds", unique); + approvers.add(step); + } + } + } catch (Exception e) { + log.error("解析审批流 flowConfig 失败", e); + } + return approvers; + } + + //update-begin---author:GHT ---date:2026-06-04 for:【MESToDing审批配置】抄送人节点映射钉钉ccList----------- + /** + * 从审批流 flowConfig JSON 解析所有 type=cc 节点,构建钉钉 ccList(去重后的 dtUserId 列表)。 + * 支持 ccType: user / role;field 跳过。 + * nodePhoneOverrides: 与审批节点共享,对自动解析失败的抄送人提供人工手机号兜底。 + * dtIdCache: 调用方传入,与审批人解析共享缓存。 + */ + private List buildCcListFromFlowConfig(String flowConfig, String accessToken, + int tenantId, Map dtIdCache, + Map> nodePhoneOverrides) { + List ccDtIds = new ArrayList<>(); + try { + JSONObject root = JSONObject.parseObject(flowConfig); + List ccNodes = new ArrayList<>(); + collectCcNodes(root, ccNodes); + + Set visitedIds = new LinkedHashSet<>(); + for (JSONObject node : ccNodes) { + String nid = node.getString("id"); + if (nid != null && !visitedIds.add(nid)) continue; + JSONObject props = node.getJSONObject("props"); + if (props == null) continue; + String ccType = oConvertUtils.getString(props.getString("ccType"), "user"); + + List nodeDtIds = new ArrayList<>(); + + if ("user".equals(ccType)) { + String userText = props.getString("userText"); + if (oConvertUtils.isNotEmpty(userText)) { + for (String uname : userText.split("[,,\\s]+")) { + uname = uname.trim(); + if (oConvertUtils.isEmpty(uname)) continue; + String dtId = resolveDtUserIdWithFallback(uname, accessToken, tenantId, dtIdCache); + if (oConvertUtils.isNotEmpty(dtId)) { + nodeDtIds.add(dtId); + } else { + log.warn("抄送节点用户 [{}] 未能自动解析到钉钉 userId", uname); + } + } + } + } else if ("role".equals(ccType)) { + JSONArray roleList = props.getJSONArray("roleList"); + if (roleList != null && !roleList.isEmpty()) { + List roleIds = new ArrayList<>(); + for (int ri = 0; ri < roleList.size(); ri++) { + String rid = roleList.getString(ri); + if (oConvertUtils.isNotEmpty(rid)) roleIds.add(rid); + } + if (!roleIds.isEmpty()) { + String inClause = String.join(",", Collections.nCopies(roleIds.size(), "?")); + List usernames = jdbcTemplate.queryForList( + "SELECT DISTINCT u.username FROM sys_user u" + + " INNER JOIN sys_user_role sur ON sur.user_id = u.id" + + " WHERE sur.role_id IN (" + inClause + ")" + + " AND (u.del_flag = 0 OR u.del_flag IS NULL)", + String.class, roleIds.toArray()); + for (String uname : usernames) { + String dtId = resolveDtUserIdWithFallback(uname, accessToken, tenantId, dtIdCache); + if (oConvertUtils.isNotEmpty(dtId)) { + nodeDtIds.add(dtId); + } else { + log.warn("抄送角色成员 [{}] 未能自动解析到钉钉 userId", uname); + } + } + } + } + } else { + log.info("抄送节点 ccType={} 不支持自动解析,已跳过(节点名={})", ccType, node.getString("name")); + } + + // 用前端手动补充的手机号填补抄送节点的缺口(与审批节点共用同一套 nodePhoneOverrides) + List manualPhones = nodePhoneOverrides.getOrDefault(nid, Collections.emptyList()); + for (String phone : manualPhones) { + if (oConvertUtils.isEmpty(phone)) continue; + Response resp = JdtUserAPI.getUseridByMobile(phone, accessToken); + if (resp.isSuccess() && oConvertUtils.isNotEmpty(resp.getResult())) { + nodeDtIds.add(resp.getResult()); + log.info("抄送节点 [{}] 手动补充手机号 {} 解析成功", nid, phone); + } else { + log.warn("抄送节点 [{}] 手动补充手机号 {} 未找到钉钉用户,已跳过", nid, phone); + } + } + + ccDtIds.addAll(nodeDtIds); + } + } catch (Exception e) { + log.error("解析抄送人节点失败", e); + } + return new ArrayList<>(new LinkedHashSet<>(ccDtIds)); + } + + /** + * 根据流程设计中抄送节点的位置,确定钉钉 ccPosition。 + * 沿主干(childNode 链)线性遍历,记录审批/抄送节点出现顺序: + * - 所有 cc 节点均在第一个 approver 之前 → START + * - 所有 cc 节点均在最后一个 approver 之后 → FINISH + * - 其他(cc 在中间,或两端均有)→ START_FINISH + * 条件分支取第一条分支做近似判断。 + */ + private String determineCcPosition(String flowConfig) { + try { + List sequence = new ArrayList<>(); + collectNodeTypeSequence(JSONObject.parseObject(flowConfig), sequence, new HashSet<>()); + + boolean hasCcBeforeAnyApprover = false; + boolean hasCcAfterAnyApprover = false; + boolean seenApprover = false; + + for (String type : sequence) { + if ("approver".equals(type)) { + seenApprover = true; + } else if ("cc".equals(type)) { + if (!seenApprover) { + hasCcBeforeAnyApprover = true; + } else { + hasCcAfterAnyApprover = true; + } + } + } + + if (hasCcBeforeAnyApprover && hasCcAfterAnyApprover) return "START_FINISH"; + if (hasCcBeforeAnyApprover) return "START"; + if (hasCcAfterAnyApprover) return "FINISH"; + } catch (Exception e) { + log.warn("determineCcPosition 解析失败,降级为 START_FINISH: {}", e.getMessage()); + } + return "START_FINISH"; + } + + /** 沿主干收集 approver / cc 节点类型序列;遇条件分支取第一条子分支 */ + private void collectNodeTypeSequence(JSONObject node, List sequence, Set visited) { + if (node == null) return; + String id = node.getString("id"); + if (id != null && !visited.add(id)) return; + String type = node.getString("type"); + if ("approver".equals(type) || "cc".equals(type)) { + sequence.add(type); + } + // 条件分支:取第一条分支递归 + JSONArray conditionNodes = node.getJSONArray("conditionNodes"); + if (conditionNodes != null && !conditionNodes.isEmpty()) { + Object first = conditionNodes.get(0); + if (first instanceof JSONObject) { + collectNodeTypeSequence((JSONObject) first, sequence, visited); + } + } + JSONObject child = node.getJSONObject("childNode"); + if (child != null) collectNodeTypeSequence(child, sequence, visited); + } + + /** 深度遍历节点树,收集所有 type=cc 的节点 */ + private void collectCcNodes(JSONObject node, List result) { + if (node == null) return; + if ("cc".equals(node.getString("type"))) { + result.add(node); + } + JSONObject child = node.getJSONObject("childNode"); + if (child != null) collectCcNodes(child, result); + JSONArray conditionNodes = node.getJSONArray("conditionNodes"); + if (conditionNodes != null) { + for (int i = 0; i < conditionNodes.size(); i++) { + Object cn = conditionNodes.get(i); + if (cn instanceof JSONObject) collectCcNodes((JSONObject) cn, result); + } + } + } + //update-end---author:GHT ---date:2026-06-04 for:【MESToDing审批配置】抄送人节点映射钉钉ccList----------- + + /** + * 按用户名解析钉钉 userId,带本次调用级缓存。 + * 解析顺序: + * ① sys_third_account(已完成钉钉账号绑定) + * ② sys_user.phone → JdtUserAPI.getUseridByMobile(手机号已在企业钉钉注册) + */ + private String resolveDtUserIdWithFallback(String username, String accessToken, int tenantId, + Map cache) { + if (cache.containsKey(username)) { + return cache.get(username); + } + // ① sys_third_account + String dtId = resolveDtUserId(username, null, accessToken, tenantId); + if (oConvertUtils.isNotEmpty(dtId)) { + cache.put(username, dtId); + return dtId; + } + // ② 从 sys_user 取手机号再查 + try { + List phones = jdbcTemplate.queryForList( + "SELECT phone FROM sys_user WHERE username = ? AND (del_flag = 0 OR del_flag IS NULL) LIMIT 1", + String.class, username); + if (!phones.isEmpty() && oConvertUtils.isNotEmpty(phones.get(0))) { + String phone = phones.get(0).trim(); + Response resp = JdtUserAPI.getUseridByMobile(phone, accessToken); + if (resp.isSuccess() && oConvertUtils.isNotEmpty(resp.getResult())) { + dtId = resp.getResult(); + cache.put(username, dtId); + return dtId; + } + } + } catch (Exception e) { + log.warn("查询用户 {} 手机号失败: {}", username, e.getMessage()); + } + cache.put(username, null); + return null; + } + //update-end---author:GHT ---date:2026-06-04 for:【MESToDing审批配置】审批流解析审批人-兜底用sys_user手机号查钉钉----------- + + /** 深度遍历节点树,收集所有 type=approver 的节点(包含条件分支路径上的节点) */ + private void collectApproverNodes(JSONObject node, List result) { + if (node == null) return; + String type = node.getString("type"); + if ("approver".equals(type)) { + result.add(node); + } + JSONObject child = node.getJSONObject("childNode"); + if (child != null) collectApproverNodes(child, result); + JSONArray conditionNodes = node.getJSONArray("conditionNodes"); + if (conditionNodes != null) { + for (int i = 0; i < conditionNodes.size(); i++) { + Object cn = conditionNodes.get(i); + if (cn instanceof JSONObject) collectApproverNodes((JSONObject) cn, result); + } + } + } + + //update-begin---author:GHT ---date:2026-06-03 for:【MESToDing审批配置】通过userId查用户所属部门ID----------- + /** + * 通过 topapi/v2/user/get 查询用户所属第一个部门 ID。 + * 注意:响应中 dept_id_list 是 JSON 字符串(如 "[2,3,4]"),需二次解析。 + * 查询失败则降级返回 -1(DingTalk 默认部门),保证流程不中断。 + */ + private long resolveUserDeptId(String dtUserId, String accessToken) { + if (oConvertUtils.isEmpty(dtUserId)) { + return -1L; + } + try { + String url = "https://oapi.dingtalk.com/topapi/v2/user/get?access_token=" + accessToken; + JSONObject reqBody = new JSONObject(); + reqBody.put("userid", dtUserId); + reqBody.put("language", "zh_CN"); + HttpClient httpClient = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(10)).build(); + HttpRequest httpReq = HttpRequest.newBuilder() + .uri(URI.create(url)) + .header("Content-Type", "application/json") + .POST(HttpRequest.BodyPublishers.ofString(reqBody.toJSONString())) + .timeout(Duration.ofSeconds(10)) + .build(); + String respBody = httpClient.send(httpReq, HttpResponse.BodyHandlers.ofString()).body(); + JSONObject resp = JSONObject.parseObject(respBody); + if (resp.getIntValue("errcode") != 0) { + log.warn("查询钉钉用户部门失败 userId={}: errcode={} errmsg={}", + dtUserId, resp.getIntValue("errcode"), resp.getString("errmsg")); + return -1L; + } + JSONObject result = resp.getJSONObject("result"); + if (result == null) { + return -1L; + } + // dept_id_list 返回的是 JSON 字符串,如 "[2,3,4]",需二次解析 + Object deptRaw = result.get("dept_id_list"); + JSONArray deptIdList = null; + if (deptRaw instanceof JSONArray) { + deptIdList = (JSONArray) deptRaw; + } else if (deptRaw instanceof String) { + deptIdList = JSONArray.parseArray((String) deptRaw); + } + if (deptIdList != null && !deptIdList.isEmpty()) { + long deptId = deptIdList.getLongValue(0); + log.info("钉钉用户部门查询成功 userId={} deptId={}", dtUserId, deptId); + return deptId; + } + } catch (Exception e) { + log.warn("查询钉钉用户部门异常 userId={}: {}", dtUserId, e.getMessage()); + } + return -1L; + } + //update-end---author:GHT ---date:2026-06-03 for:【MESToDing审批配置】通过userId查用户所属部门ID----------- + + /** + * 解析用户的钉钉 userId:优先从 sys_third_account 查已绑定的,其次用手机号降级查询。 + */ + private String resolveDtUserId(String username, String phone, String accessToken, int tenantId) { + if (oConvertUtils.isNotEmpty(username)) { + List accounts = sysThirdAccountService.listThirdUserIdByUsername( + new String[]{username}, "dingtalk", tenantId); + if (accounts != null && !accounts.isEmpty() && oConvertUtils.isNotEmpty(accounts.get(0).getThirdUserId())) { + return accounts.get(0).getThirdUserId(); + } + } + if (oConvertUtils.isNotEmpty(phone)) { + Response resp = JdtUserAPI.getUseridByMobile(phone, accessToken); + if (resp.isSuccess() && oConvertUtils.isNotEmpty(resp.getResult())) { + return resp.getResult(); + } + } + return null; + } + + //update-begin---author:GHT ---date:20260604 for:【QH-MES审批台账】钉钉发起参数解析辅助----- + private String str(Object value) { + return value == null ? "" : String.valueOf(value).trim(); + } + + private String firstNonEmpty(String first, String second) { + if (oConvertUtils.isNotEmpty(first)) { + return first.trim(); + } + return oConvertUtils.isNotEmpty(second) ? second.trim() : ""; + } + //update-end---author:GHT ---date:20260604 for:【QH-MES审批台账】钉钉发起参数解析辅助----- + //update-end---author:GHT ---date:2026-06-03 for:【MESToDing审批配置】手动填表发起钉钉审批----------- +} diff --git a/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/dingtalk/controller/MesXslDingTplBindController.java b/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/dingtalk/controller/MesXslDingTplBindController.java new file mode 100644 index 0000000..b3a3413 --- /dev/null +++ b/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/dingtalk/controller/MesXslDingTplBindController.java @@ -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> 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> rows = jdbcTemplate.queryForList(sql); + + Map nodeMap = new LinkedHashMap<>(); + for (Map 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 roots = new ArrayList<>(); + Set 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() { + List rows = bindService.list( + new QueryWrapper() + .orderByDesc("create_time")); + return Result.OK(rows); + } + + // ═══════════════════════ 模板列表 ═══════════════════════ + + @Operation(summary = "可用钉钉审批模板列表(状态启用)") + @GetMapping("/tplList") + @RequiresPermissions("xslmes:mesXslDingTplBind:list") + public Result> tplList() { + List list = tplService.list( + new QueryWrapper() + .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> 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> 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> 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 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 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 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 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 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 children; + } + + @Data + public static class SaveBindRequest { + private String bizCode; + private String bizName; + private String templateId; + private String fieldMappingJson; + } +} diff --git a/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/dingtalk/dto/DingFormComponent.java b/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/dingtalk/dto/DingFormComponent.java new file mode 100644 index 0000000..71e91fe --- /dev/null +++ b/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/dingtalk/dto/DingFormComponent.java @@ -0,0 +1,43 @@ +package org.jeecg.modules.xslmes.dingtalk.dto; + +import lombok.Data; +import lombok.experimental.Accessors; + +import java.util.List; + +/** + * 钉钉审批表单控件 + * 对应官方 SDK:FormComponent + * + * 支持的 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 children; +} diff --git a/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/dingtalk/dto/DingFormComponentProps.java b/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/dingtalk/dto/DingFormComponentProps.java new file mode 100644 index 0000000..e92bf40 --- /dev/null +++ b/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/dingtalk/dto/DingFormComponentProps.java @@ -0,0 +1,99 @@ +package org.jeecg.modules.xslmes.dingtalk.dto; + +import lombok.Data; +import lombok.experimental.Accessors; + +import java.util.List; + +/** + * 钉钉审批表单控件属性 + * 对应官方 SDK:FormComponentProps + */ +@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 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; + + /** 可关联的审批单列表,RelateField 使用 */ + private List 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; + } +} diff --git a/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/dingtalk/dto/DingFormCreateRequest.java b/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/dingtalk/dto/DingFormCreateRequest.java new file mode 100644 index 0000000..66f6e3d --- /dev/null +++ b/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/dingtalk/dto/DingFormCreateRequest.java @@ -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 + * 对应官方 SDK:FormCreateRequest + */ +@Data +@Accessors(chain = true) +public class DingFormCreateRequest { + + /** 模板名称 */ + private String name; + + /** 模板描述 */ + private String description; + + /** 表单控件列表 */ + private List formComponents; +} diff --git a/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/dingtalk/dto/DingFormUpdateRequest.java b/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/dingtalk/dto/DingFormUpdateRequest.java new file mode 100644 index 0000000..ec33fd0 --- /dev/null +++ b/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/dingtalk/dto/DingFormUpdateRequest.java @@ -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 + * 对应官方 SDK:FormUpdateRequest + */ +@Data +@Accessors(chain = true) +public class DingFormUpdateRequest { + + /** 要更新的模板 processCode */ + private String processCode; + + /** 模板名称 */ + private String name; + + /** 模板描述 */ + private String description; + + /** 表单控件列表 */ + private List formComponents; +} diff --git a/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/dingtalk/entity/MesXslDingProcessTpl.java b/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/dingtalk/entity/MesXslDingProcessTpl.java new file mode 100644 index 0000000..08736ee --- /dev/null +++ b/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/dingtalk/entity/MesXslDingProcessTpl.java @@ -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; +} diff --git a/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/dingtalk/entity/MesXslDingTplBind.java b/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/dingtalk/entity/MesXslDingTplBind.java new file mode 100644 index 0000000..5642e9d --- /dev/null +++ b/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/dingtalk/entity/MesXslDingTplBind.java @@ -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; +} diff --git a/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/dingtalk/mapper/MesXslDingProcessTplMapper.java b/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/dingtalk/mapper/MesXslDingProcessTplMapper.java new file mode 100644 index 0000000..d9e177d --- /dev/null +++ b/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/dingtalk/mapper/MesXslDingProcessTplMapper.java @@ -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 { +} diff --git a/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/dingtalk/mapper/MesXslDingTplBindMapper.java b/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/dingtalk/mapper/MesXslDingTplBindMapper.java new file mode 100644 index 0000000..bbfd9f3 --- /dev/null +++ b/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/dingtalk/mapper/MesXslDingTplBindMapper.java @@ -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 { +} diff --git a/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/dingtalk/mapper/xml/MesXslDingProcessTplMapper.xml b/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/dingtalk/mapper/xml/MesXslDingProcessTplMapper.xml new file mode 100644 index 0000000..58e06c0 --- /dev/null +++ b/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/dingtalk/mapper/xml/MesXslDingProcessTplMapper.xml @@ -0,0 +1,4 @@ + + + + diff --git a/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/dingtalk/mapper/xml/MesXslDingTplBindMapper.xml b/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/dingtalk/mapper/xml/MesXslDingTplBindMapper.xml new file mode 100644 index 0000000..a59e3b7 --- /dev/null +++ b/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/dingtalk/mapper/xml/MesXslDingTplBindMapper.xml @@ -0,0 +1,4 @@ + + + + diff --git a/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/dingtalk/service/IMesXslDingProcessTplService.java b/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/dingtalk/service/IMesXslDingProcessTplService.java new file mode 100644 index 0000000..f8e1eb9 --- /dev/null +++ b/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/dingtalk/service/IMesXslDingProcessTplService.java @@ -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 { +} diff --git a/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/dingtalk/service/IMesXslDingTplBindService.java b/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/dingtalk/service/IMesXslDingTplBindService.java new file mode 100644 index 0000000..9b18bfd --- /dev/null +++ b/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/dingtalk/service/IMesXslDingTplBindService.java @@ -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 getByBizCode(String bizCode); +} diff --git a/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/dingtalk/service/impl/MesXslDingProcessTplServiceImpl.java b/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/dingtalk/service/impl/MesXslDingProcessTplServiceImpl.java new file mode 100644 index 0000000..0e2d3fc --- /dev/null +++ b/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/dingtalk/service/impl/MesXslDingProcessTplServiceImpl.java @@ -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 + implements IMesXslDingProcessTplService { +} diff --git a/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/dingtalk/service/impl/MesXslDingTplBindServiceImpl.java b/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/dingtalk/service/impl/MesXslDingTplBindServiceImpl.java new file mode 100644 index 0000000..e539b3a --- /dev/null +++ b/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/dingtalk/service/impl/MesXslDingTplBindServiceImpl.java @@ -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 + implements IMesXslDingTplBindService { + + @Override + public MesXslDingTplBind getByBizCode(String bizCode) { + return getOne(new QueryWrapper().eq("biz_code", bizCode).last("LIMIT 1")); + } +} diff --git a/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/dingtalk/stream/DingBpmsEventProcessor.java b/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/dingtalk/stream/DingBpmsEventProcessor.java new file mode 100644 index 0000000..de568f8 --- /dev/null +++ b/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/dingtalk/stream/DingBpmsEventProcessor.java @@ -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; + +/** + * 钉钉审批事件处理器。 + *

+ * 收到 Stream 事件后,主动调用 {@link DingTalkWorkflowService#getProcessInstance} 拉取完整实例数据, + * 通过 {@code operationRecords} 精准得知"哪个节点由谁操作",从而: + *

    + *
  • 用审批人自己的 JWT Token 调用业务接口(proofread_by/audit_by 写入真实操作人);
  • + *
  • 以 operationRecord 的顺序索引映射到 MES 流程节点,执行节点配置的 callbackActions;
  • + *
  • 同时触发 {@link org.jeecg.modules.xslmes.approval.callback.IApprovalBizCallback} Bean 回调。
  • + *
+ * + * @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=RUNNING,0行更新即终态已处理----- + // ② 更新台账(乐观条件: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=RUNNING,0行更新即终态已处理----- + + 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 taskOps = workflowService.getTaskOperations(instance); + List 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----- + /** + * 节点任务变更:每个审批人操作时触发。 + *

+ * 节点通过时: + *

    + *
  1. 拉取审批实例详情,从 operationRecords 得到"已完成任务列表";
  2. + *
  3. 最后一条完成操作的索引 = 本次节点在 MES 流程中的位置;
  4. + *
  5. 用该操作人的 Token 执行对应 MES 节点的 onNodeApprove 回调接口;
  6. + *
  7. 触发 IApprovalBizCallback.onNodeApproved。
  8. + *
+ */ + 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 taskOps = workflowService.getTaskOperations(instance); + List 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,并发安全且重启不丢----- + // tryMarkNodeProcessed:UPDATE ... SET processed_op_count=nodeIndex+1 WHERE processed_op_count list = approvalRecordService.list( + new LambdaQueryWrapper() + .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 loadApproverNodes(String flowId) { + List 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 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 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 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("钉钉审批"); + } +} diff --git a/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/dingtalk/stream/DingTalkStreamClient.java b/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/dingtalk/stream/DingTalkStreamClient.java new file mode 100644 index 0000000..f0859c7 --- /dev/null +++ b/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/dingtalk/stream/DingTalkStreamClient.java @@ -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)。 + *

+ * 无需注册公网回调地址:应用主动建立长连接,钉钉通过该通道推送事件(如审批结果), + * 官方 SDK 内部自动维护重连与心跳。 + *

+ * 启动时机:{@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回调】处理事件并回写审批台账----- +} diff --git a/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/dingtalk/stream/DingTalkWorkflowService.java b/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/dingtalk/stream/DingTalkWorkflowService.java new file mode 100644 index 0000000..377ad60 --- /dev/null +++ b/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/dingtalk/stream/DingTalkWorkflowService.java @@ -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), + * 按时间顺序排列,每条记录对应一个审批节点的操作(通过/拒绝)。 + *

+ * 返回的列表索引与 MES 流程节点列表索引一一对应:第0条 = 第1个节点,第1条 = 第2个节点,以此类推。 + */ + public List getTaskOperations(JSONObject instanceResult) { + List 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。 + *

+ * 查询链:sys_third_account(third_user_id=dtUserId) → sys_user_id → sys_user.username/password + *

+ * 这样回调业务接口时,接口内部通过 {@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 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 并写入 Redis(key=PREFIX_USER_TOKEN+token,value=token)。 + *

+ * JeecgBoot Shiro 在 {@code jwtTokenRefresh()} 中验证 token 时, + * 必须从 Redis {@code "prefix_user_token:" + token} 取到缓存值, + * 否则报 "Token失效,请重新登录"。新生成的 token 需要手动写入 Redis 才能通过校验。 + *

+ * 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----- +} diff --git a/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/mcs/controller/McsToMesAlarmController.java b/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/mcs/controller/McsToMesAlarmController.java index b81e577..56fc6b2 100644 --- a/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/mcs/controller/McsToMesAlarmController.java +++ b/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/mcs/controller/McsToMesAlarmController.java @@ -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; diff --git a/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/mcs/controller/McsToMesAutoXlLogController.java b/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/mcs/controller/McsToMesAutoXlLogController.java index d78d068..5d915dc 100644 --- a/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/mcs/controller/McsToMesAutoXlLogController.java +++ b/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/mcs/controller/McsToMesAutoXlLogController.java @@ -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; diff --git a/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/mcs/controller/McsToMesBinToMaterController.java b/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/mcs/controller/McsToMesBinToMaterController.java index e9db809..82db6ee 100644 --- a/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/mcs/controller/McsToMesBinToMaterController.java +++ b/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/mcs/controller/McsToMesBinToMaterController.java @@ -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; diff --git a/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/mcs/controller/McsToMesCheckScaleLogController.java b/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/mcs/controller/McsToMesCheckScaleLogController.java index dd7f630..51a706c 100644 --- a/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/mcs/controller/McsToMesCheckScaleLogController.java +++ b/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/mcs/controller/McsToMesCheckScaleLogController.java @@ -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; diff --git a/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/mcs/controller/McsToMesMixActController.java b/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/mcs/controller/McsToMesMixActController.java index 2c5a525..a060eb6 100644 --- a/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/mcs/controller/McsToMesMixActController.java +++ b/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/mcs/controller/McsToMesMixActController.java @@ -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; diff --git a/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/mcs/controller/McsToMesMixAlarmController.java b/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/mcs/controller/McsToMesMixAlarmController.java index 616d6a6..3a4ad72 100644 --- a/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/mcs/controller/McsToMesMixAlarmController.java +++ b/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/mcs/controller/McsToMesMixAlarmController.java @@ -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; diff --git a/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/mcs/controller/McsToMesMixBatchController.java b/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/mcs/controller/McsToMesMixBatchController.java index 8cf0996..50cfda4 100644 --- a/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/mcs/controller/McsToMesMixBatchController.java +++ b/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/mcs/controller/McsToMesMixBatchController.java @@ -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; diff --git a/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/mcs/controller/McsToMesMixConController.java b/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/mcs/controller/McsToMesMixConController.java index e41d2d2..120a342 100644 --- a/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/mcs/controller/McsToMesMixConController.java +++ b/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/mcs/controller/McsToMesMixConController.java @@ -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; diff --git a/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/mcs/controller/McsToMesMixCurveController.java b/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/mcs/controller/McsToMesMixCurveController.java index 5342b86..f8cc9bb 100644 --- a/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/mcs/controller/McsToMesMixCurveController.java +++ b/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/mcs/controller/McsToMesMixCurveController.java @@ -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; diff --git a/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/mcs/controller/McsToMesMixExePlanController.java b/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/mcs/controller/McsToMesMixExePlanController.java index c5e91c8..9af3d48 100644 --- a/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/mcs/controller/McsToMesMixExePlanController.java +++ b/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/mcs/controller/McsToMesMixExePlanController.java @@ -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; diff --git a/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/mcs/controller/McsToMesMixStepController.java b/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/mcs/controller/McsToMesMixStepController.java index 9a6f731..2848110 100644 --- a/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/mcs/controller/McsToMesMixStepController.java +++ b/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/mcs/controller/McsToMesMixStepController.java @@ -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; diff --git a/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/mcs/controller/McsToMesMixWeightController.java b/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/mcs/controller/McsToMesMixWeightController.java index 04e4e86..4bc63db 100644 --- a/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/mcs/controller/McsToMesMixWeightController.java +++ b/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/mcs/controller/McsToMesMixWeightController.java @@ -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; diff --git a/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/mcs/controller/MesToMcsMaterialController.java b/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/mcs/controller/MesToMcsMaterialController.java index c6928fd..32c956f 100644 --- a/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/mcs/controller/MesToMcsMaterialController.java +++ b/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/mcs/controller/MesToMcsMaterialController.java @@ -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; diff --git a/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/mcs/controller/MesToMcsMixPlanController.java b/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/mcs/controller/MesToMcsMixPlanController.java index 93e8a68..ead15ad 100644 --- a/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/mcs/controller/MesToMcsMixPlanController.java +++ b/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/mcs/controller/MesToMcsMixPlanController.java @@ -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; diff --git a/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/mcs/controller/MesToMcsRecipeController.java b/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/mcs/controller/MesToMcsRecipeController.java index 351ef48..884961d 100644 --- a/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/mcs/controller/MesToMcsRecipeController.java +++ b/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/mcs/controller/MesToMcsRecipeController.java @@ -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; diff --git a/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/mcs/controller/MesToMcsRecipeMixStepController.java b/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/mcs/controller/MesToMcsRecipeMixStepController.java index 83062ae..b8963f9 100644 --- a/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/mcs/controller/MesToMcsRecipeMixStepController.java +++ b/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/mcs/controller/MesToMcsRecipeMixStepController.java @@ -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; diff --git a/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/mcs/controller/MesToMcsRecipeWeightController.java b/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/mcs/controller/MesToMcsRecipeWeightController.java index 6b98591..a60172e 100644 --- a/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/mcs/controller/MesToMcsRecipeWeightController.java +++ b/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/mcs/controller/MesToMcsRecipeWeightController.java @@ -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; diff --git a/jeecg-boot/jeecg-module-system/jeecg-system-biz/src/main/java/org/jeecg/modules/system/entity/SysThirdAppConfig.java b/jeecg-boot/jeecg-module-system/jeecg-system-biz/src/main/java/org/jeecg/modules/system/entity/SysThirdAppConfig.java index dcc1ae0..2e24cb0 100644 --- a/jeecg-boot/jeecg-module-system/jeecg-system-biz/src/main/java/org/jeecg/modules/system/entity/SysThirdAppConfig.java +++ b/jeecg-boot/jeecg-module-system/jeecg-system-biz/src/main/java/org/jeecg/modules/system/entity/SysThirdAppConfig.java @@ -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") diff --git a/jeecg-boot/jeecg-module-system/jeecg-system-biz/src/main/java/org/jeecg/modules/system/service/impl/ThirdAppDingtalkServiceImpl.java b/jeecg-boot/jeecg-module-system/jeecg-system-biz/src/main/java/org/jeecg/modules/system/service/impl/ThirdAppDingtalkServiceImpl.java index 7a4295a..c4f3df6 100644 --- a/jeecg-boot/jeecg-module-system/jeecg-system-biz/src/main/java/org/jeecg/modules/system/service/impl/ThirdAppDingtalkServiceImpl.java +++ b/jeecg-boot/jeecg-module-system/jeecg-system-biz/src/main/java/org/jeecg/modules/system/service/impl/ThirdAppDingtalkServiceImpl.java @@ -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 长连接使用。 + *

+ * 优先级: + * ① stream_enabled=1 的记录(管理员在「钉钉集成」页面显式指定的 Stream 主配置) + * ② 兜底:AppKey 长度 > 10 的第一条有效记录(迁移期或未配置时保持可用) + * + * @return [appKey, appSecret];未配置时返回 null + */ + public String[] getDingAppCredentials() { + java.util.List all = configMapper.selectList( + new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper() + .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。 + *

+ * 原 {@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 diff --git a/jeecg-boot/jeecg-module-system/jeecg-system-start/src/main/resources/application-dev.yml b/jeecg-boot/jeecg-module-system/jeecg-system-start/src/main/resources/application-dev.yml index 843eb86..0cde415 100644 --- a/jeecg-boot/jeecg-module-system/jeecg-system-start/src/main/resources/application-dev.yml +++ b/jeecg-boot/jeecg-module-system/jeecg-system-start/src/main/resources/application-dev.yml @@ -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: diff --git a/jeecg-boot/jeecg-module-system/jeecg-system-start/src/main/resources/flyway/sql/mysql/V3.9.2_100__mes_xsl_approval_record_processed_op_count.sql b/jeecg-boot/jeecg-module-system/jeecg-system-start/src/main/resources/flyway/sql/mysql/V3.9.2_100__mes_xsl_approval_record_processed_op_count.sql new file mode 100644 index 0000000..855e6bd --- /dev/null +++ b/jeecg-boot/jeecg-module-system/jeecg-system-start/src/main/resources/flyway/sql/mysql/V3.9.2_100__mes_xsl_approval_record_processed_op_count.sql @@ -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 '钉钉回调已处理节点数(幂等去重)'; diff --git a/jeecg-boot/jeecg-module-system/jeecg-system-start/src/main/resources/flyway/sql/mysql/V3.9.2_122__mes_xsl_ding_process_tpl.sql b/jeecg-boot/jeecg-module-system/jeecg-system-start/src/main/resources/flyway/sql/mysql/V3.9.2_122__mes_xsl_ding_process_tpl.sql new file mode 100644 index 0000000..801f674 --- /dev/null +++ b/jeecg-boot/jeecg-module-system/jeecg-system-start/src/main/resources/flyway/sql/mysql/V3.9.2_122__mes_xsl_ding_process_tpl.sql @@ -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 = 178046026420802,is_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'); diff --git a/jeecg-boot/jeecg-module-system/jeecg-system-start/src/main/resources/flyway/sql/mysql/V3.9.2_123__mes_xsl_ding_process_tpl_flow_id.sql b/jeecg-boot/jeecg-module-system/jeecg-system-start/src/main/resources/flyway/sql/mysql/V3.9.2_123__mes_xsl_ding_process_tpl_flow_id.sql new file mode 100644 index 0000000..118f846 --- /dev/null +++ b/jeecg-boot/jeecg-module-system/jeecg-system-start/src/main/resources/flyway/sql/mysql/V3.9.2_123__mes_xsl_ding_process_tpl_flow_id.sql @@ -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(用于发起审批时解析审批人)'; diff --git a/jeecg-boot/jeecg-module-system/jeecg-system-start/src/main/resources/flyway/sql/mysql/V3.9.2_124__mes_xsl_ding_tpl_bind.sql b/jeecg-boot/jeecg-module-system/jeecg-system-start/src/main/resources/flyway/sql/mysql/V3.9.2_124__mes_xsl_ding_tpl_bind.sql new file mode 100644 index 0000000..678dbe1 --- /dev/null +++ b/jeecg-boot/jeecg-module-system/jeecg-system-start/src/main/resources/flyway/sql/mysql/V3.9.2_124__mes_xsl_ding_tpl_bind.sql @@ -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'); diff --git a/jeecg-boot/jeecg-module-system/jeecg-system-start/src/main/resources/flyway/sql/mysql/V3.9.2_125__mes_xsl_approval_record.sql b/jeecg-boot/jeecg-module-system/jeecg-system-start/src/main/resources/flyway/sql/mysql/V3.9.2_125__mes_xsl_approval_record.sql new file mode 100644 index 0000000..307e45a --- /dev/null +++ b/jeecg-boot/jeecg-module-system/jeecg-system-start/src/main/resources/flyway/sql/mysql/V3.9.2_125__mes_xsl_approval_record.sql @@ -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()); diff --git a/jeecg-boot/jeecg-module-system/jeecg-system-start/src/main/resources/flyway/sql/mysql/V3.9.2_126__mes_xsl_approval_record_menu.sql b/jeecg-boot/jeecg-module-system/jeecg-system-start/src/main/resources/flyway/sql/mysql/V3.9.2_126__mes_xsl_approval_record_menu.sql new file mode 100644 index 0000000..f89eca2 --- /dev/null +++ b/jeecg-boot/jeecg-module-system/jeecg-system-start/src/main/resources/flyway/sql/mysql/V3.9.2_126__mes_xsl_approval_record_menu.sql @@ -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'); diff --git a/jeecg-boot/jeecg-module-system/jeecg-system-start/src/main/resources/flyway/sql/mysql/V3.9.2_127__sys_third_app_config_stream_enabled.sql b/jeecg-boot/jeecg-module-system/jeecg-system-start/src/main/resources/flyway/sql/mysql/V3.9.2_127__sys_third_app_config_stream_enabled.sql new file mode 100644 index 0000000..e3f1efb --- /dev/null +++ b/jeecg-boot/jeecg-module-system/jeecg-system-start/src/main/resources/flyway/sql/mysql/V3.9.2_127__sys_third_app_config_stream_enabled.sql @@ -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-是)'; diff --git a/jeecg-boot/jeecg-module-system/jeecg-system-start/src/main/resources/flyway/sql/mysql/V3.9.2_128__mes_xsl_approval_record_origin_status.sql b/jeecg-boot/jeecg-module-system/jeecg-system-start/src/main/resources/flyway/sql/mysql/V3.9.2_128__mes_xsl_approval_record_origin_status.sql new file mode 100644 index 0000000..64e2b17 --- /dev/null +++ b/jeecg-boot/jeecg-module-system/jeecg-system-start/src/main/resources/flyway/sql/mysql/V3.9.2_128__mes_xsl_approval_record_origin_status.sql @@ -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 '发起审批时业务状态原值(驳回回写依据)'; diff --git a/jeecg-boot/jeecg-module-system/jeecg-system-start/src/main/resources/flyway/sql/mysql/V3.9.2_129__mes_xsl_ding_process_tpl_process_code_default.sql b/jeecg-boot/jeecg-module-system/jeecg-system-start/src/main/resources/flyway/sql/mysql/V3.9.2_129__mes_xsl_ding_process_tpl_process_code_default.sql new file mode 100644 index 0000000..ab86972 --- /dev/null +++ b/jeecg-boot/jeecg-module-system/jeecg-system-start/src/main/resources/flyway/sql/mysql/V3.9.2_129__mes_xsl_ding_process_tpl_process_code_default.sql @@ -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,未推送钉钉前为空'; diff --git a/jeecgboot-vue3/src/components/DingTplLaunch/DingBindLaunchModal.vue b/jeecgboot-vue3/src/components/DingTplLaunch/DingBindLaunchModal.vue new file mode 100644 index 0000000..ab0bd35 --- /dev/null +++ b/jeecgboot-vue3/src/components/DingTplLaunch/DingBindLaunchModal.vue @@ -0,0 +1,807 @@ + + + + + diff --git a/jeecgboot-vue3/src/components/DingTplLaunch/index.vue b/jeecgboot-vue3/src/components/DingTplLaunch/index.vue new file mode 100644 index 0000000..acd1d7f --- /dev/null +++ b/jeecgboot-vue3/src/components/DingTplLaunch/index.vue @@ -0,0 +1,257 @@ + + + + + + diff --git a/jeecgboot-vue3/src/components/DingTplLaunch/useDraggablePosition.ts b/jeecgboot-vue3/src/components/DingTplLaunch/useDraggablePosition.ts new file mode 100644 index 0000000..d6ae55a --- /dev/null +++ b/jeecgboot-vue3/src/components/DingTplLaunch/useDraggablePosition.ts @@ -0,0 +1,157 @@ +import { reactive, ref, type Ref } from 'vue'; + +const STORAGE_KEY = 'mes_ding_tpl_launch_pos'; + +export interface FloatPosition { + left: number; + top: number; +} + +function readAllPositions(): Record { + try { + return JSON.parse(localStorage.getItem(STORAGE_KEY) || '{}') || {}; + } catch { + return {}; + } +} + +/** 查询条件区域(BasicTable 搜索表单)右侧的默认坐标 */ +export function calcDefaultPosition(btnWidth: number, btnHeight: number): FloatPosition { + const formEl = + document.querySelector('.jeecg-basic-table-form-container .ant-form') || + document.querySelector('.jeecg-basic-table-form-container'); + if (!formEl) { + return { + left: Math.max(8, window.innerWidth - btnWidth - 24), + top: 100, + }; + } + const rect = formEl.getBoundingClientRect(); + return { + left: Math.max(8, rect.right - btnWidth - 12), + top: Math.max(8, rect.top + (rect.height - btnHeight) / 2), + }; +} + +/** + * 可拖拽悬浮按钮位置:默认对齐查询区右侧,拖拽后按路由持久化。 + */ +export function useDraggablePosition(routePath: Ref) { + const pos = reactive({ left: 0, top: 0 }); + const isDragging = ref(false); + let moved = false; + let pointerId: number | null = null; + let startX = 0; + let startY = 0; + let startLeft = 0; + let startTop = 0; + let btnWidth = 120; + let btnHeight = 36; + + function setButtonSize(width: number, height: number) { + btnWidth = width; + btnHeight = height; + } + + function loadPosition(path: string): boolean { + const saved = readAllPositions()[path]; + if (saved && typeof saved.left === 'number' && typeof saved.top === 'number') { + pos.left = saved.left; + pos.top = saved.top; + return true; + } + return false; + } + + function savePosition(path: string) { + const all = readAllPositions(); + all[path] = { left: pos.left, top: pos.top }; + localStorage.setItem(STORAGE_KEY, JSON.stringify(all)); + } + + function applyDefaultPosition() { + const p = calcDefaultPosition(btnWidth, btnHeight); + pos.left = p.left; + pos.top = p.top; + } + + function initPosition(path: string, preferDefault = false) { + if (!path) return; + if (!preferDefault && loadPosition(path)) return; + applyDefaultPosition(); + } + + function clampPosition() { + const maxLeft = window.innerWidth - btnWidth - 8; + const maxTop = window.innerHeight - btnHeight - 8; + pos.left = Math.min(Math.max(8, pos.left), maxLeft); + pos.top = Math.min(Math.max(8, pos.top), maxTop); + } + + function onPointerMove(e: PointerEvent) { + if (pointerId !== e.pointerId) return; + const dx = e.clientX - startX; + const dy = e.clientY - startY; + if (!moved && (Math.abs(dx) > 4 || Math.abs(dy) > 4)) { + moved = true; + isDragging.value = true; + } + if (!moved) return; + pos.left = startLeft + dx; + pos.top = startTop + dy; + clampPosition(); + } + + function endDrag(path: string) { + document.removeEventListener('pointermove', onPointerMove); + document.removeEventListener('pointerup', onPointerUp); + document.removeEventListener('pointercancel', onPointerUp); + if (moved && path) { + savePosition(path); + } + pointerId = null; + setTimeout(() => { + isDragging.value = false; + moved = false; + }, 0); + } + + function onPointerUp(e: PointerEvent) { + if (pointerId !== e.pointerId) return; + endDrag(routePath.value); + } + + function onPointerDown(e: PointerEvent, el: HTMLElement | null) { + if (e.button !== 0) return; + if (el) { + const rect = el.getBoundingClientRect(); + setButtonSize(rect.width, rect.height); + } + moved = false; + isDragging.value = false; + pointerId = e.pointerId; + startX = e.clientX; + startY = e.clientY; + startLeft = pos.left; + startTop = pos.top; + (e.currentTarget as HTMLElement)?.setPointerCapture?.(e.pointerId); + document.addEventListener('pointermove', onPointerMove); + document.addEventListener('pointerup', onPointerUp); + document.addEventListener('pointercancel', onPointerUp); + } + + function wasDragged() { + return moved; + } + + return { + pos, + isDragging, + setButtonSize, + initPosition, + applyDefaultPosition, + onPointerDown, + wasDragged, + clampPosition, + }; +} diff --git a/jeecgboot-vue3/src/layouts/default/index.vue b/jeecgboot-vue3/src/layouts/default/index.vue index de43a56..db3f467 100644 --- a/jeecgboot-vue3/src/layouts/default/index.vue +++ b/jeecgboot-vue3/src/layouts/default/index.vue @@ -13,6 +13,9 @@ + + + @@ -48,6 +51,9 @@ //update-begin---author:GHT ---date:2026-05-29 for:【QH-MES审批流设计】全局审批流程设计悬浮按钮----- ApprovalDesignFloat: createAsyncComponent(() => import('/@/components/ApprovalDesign/index.vue')), //update-end---author:GHT ---date:2026-05-29 for:【QH-MES审批流设计】全局审批流程设计悬浮按钮----- + //update-begin---author:GHT ---date:2026-06-04 for:【MESToDing审批配置】全局钉钉审批模板绑定发起按钮----- + DingTplLaunchFloat: createAsyncComponent(() => import('/@/components/DingTplLaunch/index.vue')), + //update-end---author:GHT ---date:2026-06-04 for:【MESToDing审批配置】全局钉钉审批模板绑定发起按钮----- LayoutHeader, LayoutContent, LayoutSideBar, diff --git a/jeecgboot-vue3/src/views/approval/flow/components/NodeConfigDrawer.vue b/jeecgboot-vue3/src/views/approval/flow/components/NodeConfigDrawer.vue index 26480c1..b66a1d8 100644 --- a/jeecgboot-vue3/src/views/approval/flow/components/NodeConfigDrawer.vue +++ b/jeecgboot-vue3/src/views/approval/flow/components/NodeConfigDrawer.vue @@ -53,7 +53,10 @@ - + +

+ 单人审批只能指定一位,已自动保留第一位 +
@@ -65,12 +68,16 @@ 第3级主管 - - - 会签(需全部同意) + + + 单人审批 + 会签(全部同意) 或签(一人同意) 依次审批 +
+ 单人审批:仅允许指定一位审批人,对应钉钉 actionType = NONE +
@@ -193,7 +200,7 @@ diff --git a/jeecgboot-vue3/src/views/xslmes/approval/mesXslApprovalRecord/components/MesXslApprovalRecordDetailModal.vue b/jeecgboot-vue3/src/views/xslmes/approval/mesXslApprovalRecord/components/MesXslApprovalRecordDetailModal.vue new file mode 100644 index 0000000..5d6ec89 --- /dev/null +++ b/jeecgboot-vue3/src/views/xslmes/approval/mesXslApprovalRecord/components/MesXslApprovalRecordDetailModal.vue @@ -0,0 +1,33 @@ + + + diff --git a/jeecgboot-vue3/src/views/xslmes/dingtalk/dingTplBind/dingTplBind.api.ts b/jeecgboot-vue3/src/views/xslmes/dingtalk/dingTplBind/dingTplBind.api.ts new file mode 100644 index 0000000..acf2e53 --- /dev/null +++ b/jeecgboot-vue3/src/views/xslmes/dingtalk/dingTplBind/dingTplBind.api.ts @@ -0,0 +1,38 @@ +import { defHttp } from '/@/utils/http/axios'; + +const BASE = '/xslmes/dingTplBind'; + +export const getMenuTree = () => defHttp.get({ url: `${BASE}/menuTree` }); + +export const getTplList = () => defHttp.get({ url: `${BASE}/tplList` }); + +export const getBizFields = (bizCode: string) => + defHttp.get({ url: `${BASE}/bizFields`, params: { bizCode } }); + +export const getDetailSlots = (bizCode: string) => + defHttp.get({ url: `${BASE}/detailSlots`, params: { bizCode } }); + +export const getDetailFields = (bizCode: string, detailProperty: string, slotKind = 'LIST') => + defHttp.get({ url: `${BASE}/detailFields`, params: { bizCode, detailProperty, slotKind } }); + +export const getBindList = () => defHttp.get({ url: `${BASE}/list` }); + +export const getBindingByRoute = (routePath: string) => + defHttp.get({ url: `${BASE}/bindingByRoute`, params: { routePath } }, { errorMessageMode: 'none' }); + +export const getByBizCode = (bizCode: string) => + defHttp.get({ url: `${BASE}/getByBizCode`, params: { bizCode } }); + +export const saveBind = (data: { + bizCode: string; + bizName?: string; + templateId: string; + fieldMappingJson: string; +}) => defHttp.post({ url: `${BASE}/save`, data }); + +export const deleteBind = (id: string) => + defHttp.delete({ url: `${BASE}/delete`, params: { id } }); + +/** 复用现有接口:拉取钉钉模板表单字段(含 dingFields) */ +export const getTemplateDetail = (id: string) => + defHttp.get({ url: '/xslmes/mesXslDingProcessTpl/getTemplateDetail', params: { id } }); diff --git a/jeecgboot-vue3/src/views/xslmes/dingtalk/dingTplBind/index.vue b/jeecgboot-vue3/src/views/xslmes/dingtalk/dingTplBind/index.vue new file mode 100644 index 0000000..6367ac8 --- /dev/null +++ b/jeecgboot-vue3/src/views/xslmes/dingtalk/dingTplBind/index.vue @@ -0,0 +1,1132 @@ + + + + + diff --git a/jeecgboot-vue3/src/views/xslmes/dingtalk/mesXslDingProcessTpl/MesXslDingProcessTpl.api.ts b/jeecgboot-vue3/src/views/xslmes/dingtalk/mesXslDingProcessTpl/MesXslDingProcessTpl.api.ts new file mode 100644 index 0000000..be4ffcd --- /dev/null +++ b/jeecgboot-vue3/src/views/xslmes/dingtalk/mesXslDingProcessTpl/MesXslDingProcessTpl.api.ts @@ -0,0 +1,83 @@ +import { defHttp } from '/@/utils/http/axios'; +import { useMessage } from '/@/hooks/web/useMessage'; + +const { createConfirm } = useMessage(); + +enum Api { + list = '/xslmes/mesXslDingProcessTpl/list', + save = '/xslmes/mesXslDingProcessTpl/add', + edit = '/xslmes/mesXslDingProcessTpl/edit', + deleteOne = '/xslmes/mesXslDingProcessTpl/delete', + deleteBatch = '/xslmes/mesXslDingProcessTpl/deleteBatch', + importExcel = '/xslmes/mesXslDingProcessTpl/importExcel', + exportXls = '/xslmes/mesXslDingProcessTpl/exportXls', + syncFromDingtalk = '/xslmes/mesXslDingProcessTpl/syncFromDingtalk', + batchImport = '/xslmes/mesXslDingProcessTpl/batchImport', + getTemplateDetail = '/xslmes/mesXslDingProcessTpl/getTemplateDetail', + saveFieldMapping = '/xslmes/mesXslDingProcessTpl/saveFieldMapping', + addNewTemplate = '/xslmes/mesXslDingProcessTpl/addNewTemplate', + createDingTemplate = '/xslmes/mesXslDingProcessTpl/createDingTemplate', + updateDingTemplate = '/xslmes/mesXslDingProcessTpl/updateDingTemplate', + launchApproval = '/xslmes/mesXslDingProcessTpl/launchApproval', + bindFlow = '/xslmes/mesXslDingProcessTpl/bindFlow', + approvalFlowList = '/xslmes/approvalFlow/list', + previewFlowApprovers = '/xslmes/mesXslDingProcessTpl/previewFlowApprovers', +} + +export const getExportUrl = Api.exportXls; +export const getImportUrl = Api.importExcel; + +export const list = (params) => defHttp.get({ url: Api.list, params }); + +export const deleteOne = (params, handleSuccess) => + defHttp.delete({ url: Api.deleteOne, params }, { joinParamsToUrl: true }).then(() => handleSuccess()); + +export const batchDelete = (params, handleSuccess) => { + createConfirm({ + iconType: 'warning', + title: '确认删除', + content: '是否删除选中数据', + okText: '确认', + cancelText: '取消', + onOk: () => + defHttp.delete({ url: Api.deleteBatch, data: params }, { joinParamsToUrl: true }).then(() => handleSuccess()), + }); +}; + +export const saveOrUpdate = (params, isUpdate) => + defHttp.post({ url: isUpdate ? Api.edit : Api.save, params }, { successMessageMode: 'none' }); + +/** 新增审批模板草稿(返回含 id 的完整记录) */ +export const addNewTemplate = (params) => + defHttp.post({ url: Api.addNewTemplate, params }, { successMessageMode: 'none' }); + +export const syncFromDingtalk = () => defHttp.get({ url: Api.syncFromDingtalk }, { successMessageMode: 'none' }); + +export const batchImport = (params) => defHttp.post({ url: Api.batchImport, params }, { successMessageMode: 'none' }); + +export const getTemplateDetail = (id: string) => + defHttp.get({ url: Api.getTemplateDetail, params: { id } }, { successMessageMode: 'none' }); + +export const queryById = (id: string) => + defHttp.get({ url: '/xslmes/mesXslDingProcessTpl/queryById', params: { id } }, { successMessageMode: 'none' }); + +export const saveFieldMapping = (params) => + defHttp.post({ url: Api.saveFieldMapping, params }, { successMessageMode: 'none' }); + +export const createDingTemplate = (params) => + defHttp.post({ url: Api.createDingTemplate, params }, { successMessageMode: 'none' }); + +export const updateDingTemplate = (params) => + defHttp.post({ url: Api.updateDingTemplate, params }, { successMessageMode: 'none' }); + +export const launchApproval = (params) => + defHttp.post({ url: Api.launchApproval, params }, { successMessageMode: 'none' }); + +export const bindApprovalFlow = (params: { id: string; flowId: string }) => + defHttp.post({ url: Api.bindFlow, params }, { successMessageMode: 'none' }); + +export const getApprovalFlowList = (params?) => + defHttp.get({ url: Api.approvalFlowList, params }, { successMessageMode: 'none' }); + +export const previewFlowApprovers = (flowId: string) => + defHttp.get({ url: Api.previewFlowApprovers, params: { flowId } }, { successMessageMode: 'none' }); diff --git a/jeecgboot-vue3/src/views/xslmes/dingtalk/mesXslDingProcessTpl/MesXslDingProcessTpl.data.ts b/jeecgboot-vue3/src/views/xslmes/dingtalk/mesXslDingProcessTpl/MesXslDingProcessTpl.data.ts new file mode 100644 index 0000000..852ee59 --- /dev/null +++ b/jeecgboot-vue3/src/views/xslmes/dingtalk/mesXslDingProcessTpl/MesXslDingProcessTpl.data.ts @@ -0,0 +1,103 @@ +import { BasicColumn, FormSchema } from '/@/components/Table'; + +export const columns: BasicColumn[] = [ + { title: '模板名称', align: 'center', dataIndex: 'tplName', width: 180 }, + { title: '钉钉processCode', align: 'center', dataIndex: 'processCode', width: 280 }, + { title: '业务类型标识', align: 'center', dataIndex: 'bizType', width: 140 }, + { title: '状态', align: 'center', dataIndex: 'status_dictText', width: 90 }, + { title: '排序', align: 'center', dataIndex: 'sortNo', width: 80 }, + { title: '备注', align: 'center', dataIndex: 'remark', width: 200 }, + { title: '创建时间', align: 'center', dataIndex: 'createTime', width: 160 }, +]; + +export const searchFormSchema: FormSchema[] = [ + { + label: '模板名称', + field: 'tplName', + component: 'JInput', + colProps: { span: 6 }, + }, + { + label: '钉钉processCode', + field: 'processCode', + component: 'JInput', + colProps: { span: 6 }, + }, + { + label: '业务类型标识', + field: 'bizType', + component: 'JInput', + colProps: { span: 6 }, + }, + { + label: '状态', + field: 'status', + component: 'JDictSelectTag', + componentProps: { dictCode: 'mes_ding_tpl_status' }, + colProps: { span: 6 }, + }, +]; + +export const formSchema: FormSchema[] = [ + { label: '', field: 'id', component: 'Input', show: false }, + { + label: '模板名称', + field: 'tplName', + component: 'Input', + componentProps: { placeholder: '请输入模板名称' }, + dynamicRules: () => [{ required: true, message: '请输入模板名称!' }], + }, + { + label: 'processCode', + field: 'processCode', + component: 'Input', + componentProps: { + placeholder: '钉钉返回的 processCode;「新增审批模板」流程中可留空,设计器创建钉钉模板后自动回填', + }, + }, + { + label: '业务类型标识', + field: 'bizType', + component: 'Input', + componentProps: { placeholder: '供审批流关联使用,如 mixer_ps、formula_spec' }, + }, + { + label: '表单字段映射', + field: 'formFields', + component: 'InputTextArea', + componentProps: { + placeholder: '{"PS编码":"psCode","类型":"type"} —— 钉钉模板字段名→MES字段名', + rows: 4, + }, + }, + { + label: '状态', + field: 'status', + component: 'JDictSelectTag', + componentProps: { + dictCode: 'mes_ding_tpl_status', + placeholder: '请选择状态', + getPopupContainer: () => document.body, + }, + }, + { + label: '排序', + field: 'sortNo', + component: 'InputNumber', + componentProps: { placeholder: '请输入排序值', style: 'width:100%' }, + }, + { + label: '备注', + field: 'remark', + component: 'InputTextArea', + componentProps: { placeholder: '请输入备注', rows: 2 }, + }, +]; + +export const superQuerySchema = { + tplName: { title: '模板名称', order: 0, view: 'text', type: 'string' }, + processCode: { title: 'processCode', order: 1, view: 'text', type: 'string' }, + bizType: { title: '业务类型标识', order: 2, view: 'text', type: 'string' }, + status: { title: '状态', order: 3, view: 'list', type: 'string', dictCode: 'mes_ding_tpl_status' }, + sortNo: { title: '排序', order: 4, view: 'number', type: 'number' }, +}; diff --git a/jeecgboot-vue3/src/views/xslmes/dingtalk/mesXslDingProcessTpl/MesXslDingProcessTplList.vue b/jeecgboot-vue3/src/views/xslmes/dingtalk/mesXslDingProcessTpl/MesXslDingProcessTplList.vue new file mode 100644 index 0000000..7c50711 --- /dev/null +++ b/jeecgboot-vue3/src/views/xslmes/dingtalk/mesXslDingProcessTpl/MesXslDingProcessTplList.vue @@ -0,0 +1,385 @@ + + + + + diff --git a/jeecgboot-vue3/src/views/xslmes/dingtalk/mesXslDingProcessTpl/components/DingApprovalLaunchModal.vue b/jeecgboot-vue3/src/views/xslmes/dingtalk/mesXslDingProcessTpl/components/DingApprovalLaunchModal.vue new file mode 100644 index 0000000..de40b72 --- /dev/null +++ b/jeecgboot-vue3/src/views/xslmes/dingtalk/mesXslDingProcessTpl/components/DingApprovalLaunchModal.vue @@ -0,0 +1,776 @@ + + + + + diff --git a/jeecgboot-vue3/src/views/xslmes/dingtalk/mesXslDingProcessTpl/components/DingTplCreateModal.vue b/jeecgboot-vue3/src/views/xslmes/dingtalk/mesXslDingProcessTpl/components/DingTplCreateModal.vue new file mode 100644 index 0000000..b12fdf0 --- /dev/null +++ b/jeecgboot-vue3/src/views/xslmes/dingtalk/mesXslDingProcessTpl/components/DingTplCreateModal.vue @@ -0,0 +1,110 @@ + + + + diff --git a/jeecgboot-vue3/src/views/xslmes/dingtalk/mesXslDingProcessTpl/components/DingTplDesigner.vue b/jeecgboot-vue3/src/views/xslmes/dingtalk/mesXslDingProcessTpl/components/DingTplDesigner.vue new file mode 100644 index 0000000..c89dc21 --- /dev/null +++ b/jeecgboot-vue3/src/views/xslmes/dingtalk/mesXslDingProcessTpl/components/DingTplDesigner.vue @@ -0,0 +1,1208 @@ + + + + + diff --git a/jeecgboot-vue3/src/views/xslmes/dingtalk/mesXslDingProcessTpl/components/MesXslDingProcessTplModal.vue b/jeecgboot-vue3/src/views/xslmes/dingtalk/mesXslDingProcessTpl/components/MesXslDingProcessTplModal.vue new file mode 100644 index 0000000..22e0fa5 --- /dev/null +++ b/jeecgboot-vue3/src/views/xslmes/dingtalk/mesXslDingProcessTpl/components/MesXslDingProcessTplModal.vue @@ -0,0 +1,61 @@ + + + + +