From 4785c55e520bd22a140632f58879f0a6b33acdbd Mon Sep 17 00:00:00 2001 From: geht <2947093423@qq.com> Date: Thu, 4 Jun 2026 11:38:08 +0800 Subject: [PATCH] =?UTF-8?q?=E6=96=B0=E5=A2=9E=E9=92=89=E9=92=89=E5=AE=A1?= =?UTF-8?q?=E6=89=B9=E6=A8=A1=E6=9D=BF=E9=85=8D=E7=BD=AE=E5=8A=9F=E8=83=BD?= =?UTF-8?q?=EF=BC=8C=E5=8C=85=E6=8B=AC=E7=9B=B8=E5=85=B3=E5=AE=9E=E4=BD=93?= =?UTF-8?q?=E3=80=81=E6=8E=A7=E5=88=B6=E5=99=A8=E3=80=81=E6=9C=8D=E5=8A=A1?= =?UTF-8?q?=E5=8F=8A=E6=8E=A5=E5=8F=A3=E7=9A=84=E5=AE=9E=E7=8E=B0=EF=BC=8C?= =?UTF-8?q?=E6=94=AF=E6=8C=81=E5=AE=A1=E6=89=B9=E6=A8=A1=E6=9D=BF=E7=9A=84?= =?UTF-8?q?=E5=A2=9E=E5=88=A0=E6=94=B9=E6=9F=A5=E5=8F=8A=E4=BB=8E=E9=92=89?= =?UTF-8?q?=E9=92=89=E5=90=8C=E6=AD=A5=E6=A8=A1=E6=9D=BF=EF=BC=8C=E5=A2=9E?= =?UTF-8?q?=E5=BC=BA=E4=BA=86=E7=B3=BB=E7=BB=9F=E7=9A=84=E5=AE=A1=E6=89=B9?= =?UTF-8?q?=E6=B5=81=E7=AE=A1=E7=90=86=E8=83=BD=E5=8A=9B=E3=80=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../MesXslMixerPsCompileController.java | 134 ++ .../MesXslDingProcessTplController.java | 1560 +++++++++++++++++ .../dingtalk/dto/DingFormComponent.java | 43 + .../dingtalk/dto/DingFormComponentProps.java | 99 ++ .../dingtalk/dto/DingFormCreateRequest.java | 25 + .../dingtalk/dto/DingFormUpdateRequest.java | 28 + .../dingtalk/entity/MesXslDingProcessTpl.java | 73 + .../mapper/MesXslDingProcessTplMapper.java | 13 + .../mapper/xml/MesXslDingProcessTplMapper.xml | 4 + .../service/IMesXslDingProcessTplService.java | 13 + .../impl/MesXslDingProcessTplServiceImpl.java | 18 + .../V3.9.2_122__mes_xsl_ding_process_tpl.sql | 74 + ..._123__mes_xsl_ding_process_tpl_flow_id.sql | 6 + .../flow/components/NodeConfigDrawer.vue | 54 +- .../approval/flow/components/flowTypes.ts | 16 +- .../MesXslDingProcessTpl.api.ts | 78 + .../MesXslDingProcessTpl.data.ts | 102 ++ .../MesXslDingProcessTplList.vue | 346 ++++ .../components/DingApprovalLaunchModal.vue | 776 ++++++++ .../components/DingTplDesigner.vue | 1204 +++++++++++++ .../components/MesXslDingProcessTplModal.vue | 61 + .../MesXslMixerPsCompile.api.ts | 3 + .../MesXslMixerPsCompileList.vue | 36 +- 23 files changed, 4755 insertions(+), 11 deletions(-) create mode 100644 jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/dingtalk/controller/MesXslDingProcessTplController.java create mode 100644 jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/dingtalk/dto/DingFormComponent.java create mode 100644 jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/dingtalk/dto/DingFormComponentProps.java create mode 100644 jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/dingtalk/dto/DingFormCreateRequest.java create mode 100644 jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/dingtalk/dto/DingFormUpdateRequest.java create mode 100644 jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/dingtalk/entity/MesXslDingProcessTpl.java create mode 100644 jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/dingtalk/mapper/MesXslDingProcessTplMapper.java create mode 100644 jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/dingtalk/mapper/xml/MesXslDingProcessTplMapper.xml create mode 100644 jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/dingtalk/service/IMesXslDingProcessTplService.java create mode 100644 jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/dingtalk/service/impl/MesXslDingProcessTplServiceImpl.java create mode 100644 jeecg-boot/jeecg-module-system/jeecg-system-start/src/main/resources/flyway/sql/mysql/V3.9.2_122__mes_xsl_ding_process_tpl.sql create mode 100644 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 create mode 100644 jeecgboot-vue3/src/views/xslmes/dingtalk/mesXslDingProcessTpl/MesXslDingProcessTpl.api.ts create mode 100644 jeecgboot-vue3/src/views/xslmes/dingtalk/mesXslDingProcessTpl/MesXslDingProcessTpl.data.ts create mode 100644 jeecgboot-vue3/src/views/xslmes/dingtalk/mesXslDingProcessTpl/MesXslDingProcessTplList.vue create mode 100644 jeecgboot-vue3/src/views/xslmes/dingtalk/mesXslDingProcessTpl/components/DingApprovalLaunchModal.vue create mode 100644 jeecgboot-vue3/src/views/xslmes/dingtalk/mesXslDingProcessTpl/components/DingTplDesigner.vue create mode 100644 jeecgboot-vue3/src/views/xslmes/dingtalk/mesXslDingProcessTpl/components/MesXslDingProcessTplModal.vue 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..6ca03ae 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 @@ -1,14 +1,26 @@ package org.jeecg.modules.xslmes.controller; +import com.alibaba.fastjson2.JSONArray; +import com.alibaba.fastjson2.JSONObject; import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper; import com.baomidou.mybatisplus.core.metadata.IPage; import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.jeecg.dingtalk.api.core.response.Response; +import com.jeecg.dingtalk.api.user.JdtUserAPI; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.text.SimpleDateFormat; +import java.time.Duration; import java.util.Arrays; +import java.util.Date; import java.util.HashMap; +import java.util.LinkedHashMap; import java.util.Map; import lombok.extern.slf4j.Slf4j; import org.apache.shiro.SecurityUtils; @@ -22,6 +34,7 @@ import org.jeecg.common.system.vo.LoginUser; import org.jeecg.common.util.oConvertUtils; import org.jeecg.modules.system.entity.SysDepart; import org.jeecg.modules.system.service.ISysDepartService; +import org.jeecg.modules.system.service.impl.ThirdAppDingtalkServiceImpl; import org.jeecg.modules.xslmes.approval.action.ApprovalBizAction; import org.jeecg.modules.xslmes.entity.MesXslMixerPsCompile; import org.jeecg.modules.xslmes.service.IMesXslMixerPsCompileService; @@ -44,6 +57,11 @@ public class MesXslMixerPsCompileController extends JeecgController> queryPageList( @@ -283,6 +301,122 @@ public class MesXslMixerPsCompileController extends JeecgController> ddApprovalTest() { + Map result = new LinkedHashMap<>(); + + // ① 取当前登录用户 + LoginUser loginUser = (LoginUser) SecurityUtils.getSubject().getPrincipal(); + String username = loginUser.getUsername(); + String realname = oConvertUtils.isNotEmpty(loginUser.getRealname()) ? loginUser.getRealname() : username; + String phone = loginUser.getPhone(); + result.put("step1_user", realname + "(" + username + ")"); + result.put("step1_phone", oConvertUtils.isNotEmpty(phone) ? phone : "【未配置手机号】"); + + if (oConvertUtils.isEmpty(phone)) { + result.put("结论", "当前账号未配置手机号,无法查询钉钉userId"); + return Result.OK(result); + } + + // ② 获取 AccessToken + String accessToken; + try { + accessToken = dingtalkService.getAccessToken(); + } catch (Exception e) { + result.put("step2_accessToken", "获取失败: " + e.getMessage()); + return Result.OK(result); + } + if (oConvertUtils.isEmpty(accessToken)) { + result.put("step2_accessToken", "获取失败,请在[系统配置-第三方应用]中检查钉钉配置"); + return Result.OK(result); + } + result.put("step2_accessToken", accessToken.substring(0, Math.min(10, accessToken.length())) + "...[已截断]"); + + // ③ 手机号查钉钉 userId + Response userIdResp = JdtUserAPI.getUseridByMobile(phone, accessToken); + result.put("step3_getUseridByMobile_errcode", userIdResp.getErrcode()); + result.put("step3_getUseridByMobile_errmsg", userIdResp.getErrmsg()); + result.put("step3_dingtalkUserId", userIdResp.isSuccess() ? userIdResp.getResult() : "查询失败"); + + if (!userIdResp.isSuccess() || oConvertUtils.isEmpty(userIdResp.getResult())) { + result.put("结论", "手机号未在钉钉通讯录中找到对应用户,请确认该手机号已在企业钉钉中注册"); + return Result.OK(result); + } + String dtUserId = userIdResp.getResult(); + + // ④ 用真实模板 processCode 发起审批实例 + try { + String processCode = "PROC-71957EB4-6F64-4AD7-AA1C-3CD7E797687B"; // MES测试审批流 + String url = "https://api.dingtalk.com/v1.0/workflow/processInstances"; + String now = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss").format(new Date()); + + // 表单字段(对应钉钉模板中的控件标题) + JSONArray formValues = new JSONArray(); + for (String[] kv : new String[][]{ + {"PS编码", "TEST-" + System.currentTimeMillis() % 10000}, + {"类型", "MES测试"}, + {"发行日期", new SimpleDateFormat("yyyy-MM-dd").format(new Date())}, + {"发送部门", "技术部"}, + {"标题", "MES钉钉审批测试 " + now}, + }) { + JSONObject f = new JSONObject(); + f.put("name", kv[0]); + f.put("value", kv[1]); + formValues.add(f); + } + + // 审批人:用当前登录人自己做测试审批人(and会签,只有1人) + JSONArray approvers = new JSONArray(); + JSONObject approverNode = new JSONObject(); + approverNode.put("actionType", "AND"); + JSONArray approverIds = new JSONArray(); + approverIds.add(dtUserId); + approverNode.put("userIds", approverIds); + approvers.add(approverNode); + + JSONObject reqBody = new JSONObject(); + reqBody.put("processCode", processCode); + reqBody.put("originatorUserId", dtUserId); + reqBody.put("deptId", -1); + reqBody.put("approvers", approvers); + reqBody.put("formComponentValues", formValues); + + result.put("step4_请求体", reqBody); + + HttpClient httpClient = HttpClient.newBuilder() + .connectTimeout(Duration.ofSeconds(10)) + .build(); + HttpRequest httpReq = HttpRequest.newBuilder() + .uri(URI.create(url)) + .header("x-acs-dingtalk-access-token", accessToken) + .header("Content-Type", "application/json") + .POST(HttpRequest.BodyPublishers.ofString(reqBody.toJSONString())) + .timeout(Duration.ofSeconds(10)) + .build(); + String respBody = httpClient.send(httpReq, HttpResponse.BodyHandlers.ofString()).body(); + + JSONObject ddResp = JSONObject.parseObject(respBody); + result.put("step4_钉钉响应", ddResp); + if (ddResp.containsKey("instanceId") || ddResp.containsKey("processInstanceId")) { + String iid = ddResp.containsKey("instanceId") + ? ddResp.getString("instanceId") + : ddResp.getString("processInstanceId"); + result.put("结论", "✅ 审批实例创建成功!instanceId=" + iid + ",请到钉钉「待我审批」查看"); + } else { + result.put("结论", "❌ 创建失败,code=" + ddResp.getString("code") + " msg=" + ddResp.getString("message")); + } + } catch (Exception e) { + log.error("钉钉审批实例创建测试异常", e); + result.put("step4_钉钉响应", "HTTP请求异常: " + e.getMessage()); + result.put("结论", "❌ 请求钉钉接口失败"); + } + + return Result.OK(result); + } + //update-end---author:GHT ---date:2026-06-03 for:【钉钉PROC-GENERIC可行性验证】测试接口----- + //update-begin---author:jiangxh ---date:20260520 for:【密炼PS编制】仅编制状态允许删除----------- private String assertCompileStatusForDelete(MesXslMixerPsCompile entity) { if (entity == null) { 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..45ddc72 --- /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,1560 @@ +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.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; + //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) { + mesXslDingProcessTplService.save(mesXslDingProcessTpl); + return Result.OK("添加成功!"); + } + + @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"); + + // 查询本地已存在的 processCode,标记哪些已导入 + Set existCodes = new HashSet<>(); + mesXslDingProcessTplService.list().forEach(t -> existCodes.add(t.getProcessCode())); + + List> list = new ArrayList<>(); + if (processList != null) { + for (int i = 0; i < processList.size(); i++) { + JSONObject item = processList.getJSONObject(i); + Map row = new LinkedHashMap<>(); + row.put("processCode", item.getString("process_code")); + row.put("name", item.getString("name")); + row.put("description", item.getString("description")); + row.put("imported", existCodes.contains(item.getString("process_code"))); + list.add(row); + } + } + 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("请选择要导入的模板"); + } + Set existCodes = new HashSet<>(); + mesXslDingProcessTplService.list().forEach(t -> existCodes.add(t.getProcessCode())); + + List toSave = new ArrayList<>(); + for (Map item : items) { + String code = String.valueOf(item.getOrDefault("processCode", "")); + String name = String.valueOf(item.getOrDefault("name", "")); + if (oConvertUtils.isEmpty(code) || existCodes.contains(code)) { + continue; + } + MesXslDingProcessTpl tpl = new MesXslDingProcessTpl(); + tpl.setProcessCode(code); + tpl.setTplName(name); + tpl.setStatus("1"); + tpl.setSortNo(0); + toSave.add(tpl); + } + if (toSave.isEmpty()) { + return Result.OK("所选模板均已存在,无需重复导入"); + } + mesXslDingProcessTplService.saveBatch(toSave); + return Result.OK("成功导入 " + toSave.size() + " 个模板"); + } + //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=新建)") + @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") + ")"); + } + + String processCode = resp.getString("processCode"); + if (oConvertUtils.isNotEmpty(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=更新已有)") + @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"; + } + + /** 统一 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); + + // ⑥ 组装钉钉发起审批请求体 + 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") + ")"); + } + + Map result = new LinkedHashMap<>(); + result.put("instanceId", resp.getString("instanceId")); + 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-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/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/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/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/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/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-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/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/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/DingTplDesigner.vue b/jeecgboot-vue3/src/views/xslmes/dingtalk/mesXslDingProcessTpl/components/DingTplDesigner.vue new file mode 100644 index 0000000..38298c1 --- /dev/null +++ b/jeecgboot-vue3/src/views/xslmes/dingtalk/mesXslDingProcessTpl/components/DingTplDesigner.vue @@ -0,0 +1,1204 @@ + + + + + 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 @@ + + + + + diff --git a/jeecgboot-vue3/src/views/xslmes/mesXslMixerPsCompile/MesXslMixerPsCompile.api.ts b/jeecgboot-vue3/src/views/xslmes/mesXslMixerPsCompile/MesXslMixerPsCompile.api.ts index 42338db..0e0f75e 100644 --- a/jeecgboot-vue3/src/views/xslmes/mesXslMixerPsCompile/MesXslMixerPsCompile.api.ts +++ b/jeecgboot-vue3/src/views/xslmes/mesXslMixerPsCompile/MesXslMixerPsCompile.api.ts @@ -12,6 +12,7 @@ enum Api { proofread = '/xslmes/mesXslMixerPsCompile/proofread', audit = '/xslmes/mesXslMixerPsCompile/audit', approve = '/xslmes/mesXslMixerPsCompile/approve', + ddApprovalTest = '/xslmes/mesXslMixerPsCompile/ddApprovalTest', } export const list = (params) => defHttp.get({ url: Api.list, params }); @@ -37,3 +38,5 @@ export const proofread = (params: { ids: string }) => defHttp.post({ url: Api.pr export const audit = (params: { ids: string }) => defHttp.post({ url: Api.audit, params }, { joinParamsToUrl: true }); export const approve = (params: { ids: string }) => defHttp.post({ url: Api.approve, params }, { joinParamsToUrl: true }); + +export const ddApprovalTest = () => defHttp.post({ url: Api.ddApprovalTest }, { successMessageMode: 'none' }); diff --git a/jeecgboot-vue3/src/views/xslmes/mesXslMixerPsCompile/MesXslMixerPsCompileList.vue b/jeecgboot-vue3/src/views/xslmes/mesXslMixerPsCompile/MesXslMixerPsCompileList.vue index 1253209..a645189 100644 --- a/jeecgboot-vue3/src/views/xslmes/mesXslMixerPsCompile/MesXslMixerPsCompileList.vue +++ b/jeecgboot-vue3/src/views/xslmes/mesXslMixerPsCompile/MesXslMixerPsCompileList.vue @@ -65,6 +65,11 @@ + + + 钉钉审批测试 + +