From aefa44b8a912564283707eb6bd843885ef42462d Mon Sep 17 00:00:00 2001 From: geht <2947093423@qq.com> Date: Fri, 29 May 2026 15:49:10 +0800 Subject: [PATCH] =?UTF-8?q?=E6=96=B0=E5=A2=9EMES=E5=AE=A1=E6=89=B9?= =?UTF-8?q?=E6=B5=81=E8=AE=BE=E8=AE=A1=E5=8A=9F=E8=83=BD=EF=BC=8C=E5=8C=85?= =?UTF-8?q?=E6=8B=AC=E5=AE=A1=E6=89=B9=E6=B5=81=E5=AE=9A=E4=B9=89=E3=80=81?= =?UTF-8?q?=E5=AE=A1=E6=89=B9=E5=AE=9E=E4=BE=8B=E7=AE=A1=E7=90=86=E5=8F=8A?= =?UTF-8?q?=E5=AE=A1=E6=89=B9=E5=8A=9E=E7=90=86=E6=8E=A5=E5=8F=A3=EF=BC=8C?= =?UTF-8?q?=E6=94=AF=E6=8C=81=E5=8F=AF=E8=A7=86=E5=8C=96=E8=AE=BE=E8=AE=A1?= =?UTF-8?q?=E4=B8=8E=E4=B8=9A=E5=8A=A1=E5=8D=95=E6=8D=AE=E8=81=94=E5=8A=A8?= =?UTF-8?q?=EF=BC=8C=E6=8F=90=E5=8D=87=E5=AE=A1=E6=89=B9=E6=B5=81=E7=A8=8B?= =?UTF-8?q?=E7=9A=84=E7=81=B5=E6=B4=BB=E6=80=A7=E4=B8=8E=E7=94=A8=E6=88=B7?= =?UTF-8?q?=E4=BD=93=E9=AA=8C=E3=80=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../jeecg-module-xslmes/doc/代码修改日志 | 55 + .../callback/ApprovalActionEvent.java | 27 + .../callback/ApprovalActionHttpExecutor.java | 169 +++ .../callback/ApprovalCallbackContext.java | 80 ++ .../callback/ApprovalCallbackDispatcher.java | 115 ++ .../callback/IApprovalBizCallback.java | 64 ++ .../RubberQuickTestStdApprovalCallback.java | 59 + .../MesXslApprovalFlowController.java | 433 +++++++ .../MesXslApprovalHandleController.java | 74 ++ .../MesXslApprovalLaunchController.java | 242 ++++ .../approval/entity/MesXslApprovalFlow.java | 68 ++ .../entity/MesXslApprovalInstance.java | 95 ++ .../mapper/MesXslApprovalFlowMapper.java | 15 + .../mapper/MesXslApprovalInstanceMapper.java | 15 + .../service/IMesXslApprovalFlowService.java | 13 + .../service/IMesXslApprovalHandleService.java | 44 + .../IMesXslApprovalInstanceService.java | 13 + .../impl/MesXslApprovalFlowServiceImpl.java | 17 + .../impl/MesXslApprovalHandleServiceImpl.java | 1023 +++++++++++++++++ .../MesXslApprovalInstanceServiceImpl.java | 17 + .../modules/im/service/ISysImChatService.java | 15 + .../im/service/impl/SysImChatServiceImpl.java | 51 + .../V3.9.2_111__mes_xsl_approval_flow.sql | 97 ++ .../V3.9.2_112__mes_xsl_approval_instance.sql | 47 + ...3.9.2_113__mes_xsl_approval_flow_route.sql | 5 + .../V3.9.2_114__mes_xsl_approval_handle.sql | 6 + .../src/components/ApprovalDesign/index.vue | 113 ++ .../src/components/ApprovalLaunch/index.vue | 283 +++++ .../ApprovalLaunch/useApprovalSelection.ts | 33 + .../src/hooks/system/useListPage.ts | 15 +- jeecgboot-vue3/src/layouts/default/index.vue | 12 + jeecgboot-vue3/src/utils/flowApiRecorder.ts | 94 ++ jeecgboot-vue3/src/utils/http/axios/index.ts | 6 + .../views/approval/flow/ApprovalFlowList.vue | 128 +++ .../views/approval/flow/ApprovalFlowModal.vue | 51 + .../views/approval/flow/approvalFlow.api.ts | 75 ++ .../views/approval/flow/approvalFlow.data.ts | 112 ++ .../views/approval/flow/approvalHandle.api.ts | 25 + .../approval/flow/components/FlowDesign.vue | 189 +++ .../approval/flow/components/FlowNode.vue | 99 ++ .../flow/components/NodeConfigDrawer.vue | 359 ++++++ .../views/approval/flow/components/flow.less | 428 +++++++ .../approval/flow/components/flowTypes.ts | 267 +++++ .../src/views/approval/flow/launch.api.ts | 34 + .../views/system/im/ImApprovalDetailModal.vue | 154 +++ .../system/im/ImBizRecordMessageContent.vue | 504 ++++---- jeecgboot-vue3/src/views/system/im/ImChat.vue | 14 + .../src/views/system/im/imBizRecordMessage.ts | 10 + 48 files changed, 5603 insertions(+), 261 deletions(-) create mode 100644 jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/callback/ApprovalActionEvent.java create mode 100644 jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/callback/ApprovalActionHttpExecutor.java create mode 100644 jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/callback/ApprovalCallbackContext.java create mode 100644 jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/callback/ApprovalCallbackDispatcher.java create mode 100644 jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/callback/IApprovalBizCallback.java create mode 100644 jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/callback/impl/RubberQuickTestStdApprovalCallback.java create mode 100644 jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/controller/MesXslApprovalFlowController.java create mode 100644 jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/controller/MesXslApprovalHandleController.java create mode 100644 jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/controller/MesXslApprovalLaunchController.java create mode 100644 jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/entity/MesXslApprovalFlow.java create mode 100644 jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/entity/MesXslApprovalInstance.java create mode 100644 jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/mapper/MesXslApprovalFlowMapper.java create mode 100644 jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/mapper/MesXslApprovalInstanceMapper.java create mode 100644 jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/service/IMesXslApprovalFlowService.java create mode 100644 jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/service/IMesXslApprovalHandleService.java create mode 100644 jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/service/IMesXslApprovalInstanceService.java create mode 100644 jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/service/impl/MesXslApprovalFlowServiceImpl.java create mode 100644 jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/service/impl/MesXslApprovalHandleServiceImpl.java create mode 100644 jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/service/impl/MesXslApprovalInstanceServiceImpl.java create mode 100644 jeecg-boot/jeecg-module-system/jeecg-system-start/src/main/resources/flyway/sql/mysql/V3.9.2_111__mes_xsl_approval_flow.sql create mode 100644 jeecg-boot/jeecg-module-system/jeecg-system-start/src/main/resources/flyway/sql/mysql/V3.9.2_112__mes_xsl_approval_instance.sql create mode 100644 jeecg-boot/jeecg-module-system/jeecg-system-start/src/main/resources/flyway/sql/mysql/V3.9.2_113__mes_xsl_approval_flow_route.sql create mode 100644 jeecg-boot/jeecg-module-system/jeecg-system-start/src/main/resources/flyway/sql/mysql/V3.9.2_114__mes_xsl_approval_handle.sql create mode 100644 jeecgboot-vue3/src/components/ApprovalDesign/index.vue create mode 100644 jeecgboot-vue3/src/components/ApprovalLaunch/index.vue create mode 100644 jeecgboot-vue3/src/components/ApprovalLaunch/useApprovalSelection.ts create mode 100644 jeecgboot-vue3/src/utils/flowApiRecorder.ts create mode 100644 jeecgboot-vue3/src/views/approval/flow/ApprovalFlowList.vue create mode 100644 jeecgboot-vue3/src/views/approval/flow/ApprovalFlowModal.vue create mode 100644 jeecgboot-vue3/src/views/approval/flow/approvalFlow.api.ts create mode 100644 jeecgboot-vue3/src/views/approval/flow/approvalFlow.data.ts create mode 100644 jeecgboot-vue3/src/views/approval/flow/approvalHandle.api.ts create mode 100644 jeecgboot-vue3/src/views/approval/flow/components/FlowDesign.vue create mode 100644 jeecgboot-vue3/src/views/approval/flow/components/FlowNode.vue create mode 100644 jeecgboot-vue3/src/views/approval/flow/components/NodeConfigDrawer.vue create mode 100644 jeecgboot-vue3/src/views/approval/flow/components/flow.less create mode 100644 jeecgboot-vue3/src/views/approval/flow/components/flowTypes.ts create mode 100644 jeecgboot-vue3/src/views/approval/flow/launch.api.ts create mode 100644 jeecgboot-vue3/src/views/system/im/ImApprovalDetailModal.vue diff --git a/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/doc/代码修改日志 b/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/doc/代码修改日志 index 13eea8a..bd75771 100644 --- a/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/doc/代码修改日志 +++ b/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/doc/代码修改日志 @@ -466,3 +466,58 @@ jeecgboot-vue3/src/views/xslmes/mesXslRubberQuickTestRecord/MesXslRubberQuickTes jeecgboot-vue3/src/views/xslmes/mesXslRubberQuickTestRecord/MesXslRubberQuickTestRecord.data.ts jeecgboot-vue3/src/views/xslmes/mesXslRubberQuickTestRecord/components/MesXslRubberQuickTestRecordModal.vue jeecgboot-vue3/src/views/mes/material/MesMaterialList.vue + +-- author:GHT---date:20260529--for: 【QH-MES审批流设计】我的租户下新增审批流设计,钉钉式可视化拖拽设计(先选单据再设计流程),本期实现设计器 --- +jeecg-boot/jeecg-module-system/jeecg-system-start/src/main/resources/flyway/sql/mysql/V3.9.2_111__mes_xsl_approval_flow.sql +jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/entity/MesXslApprovalFlow.java +jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/mapper/MesXslApprovalFlowMapper.java +jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/service/IMesXslApprovalFlowService.java +jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/service/impl/MesXslApprovalFlowServiceImpl.java +jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/controller/MesXslApprovalFlowController.java +jeecgboot-vue3/src/views/approval/flow/approvalFlow.api.ts +jeecgboot-vue3/src/views/approval/flow/approvalFlow.data.ts +jeecgboot-vue3/src/views/approval/flow/ApprovalFlowList.vue +jeecgboot-vue3/src/views/approval/flow/ApprovalFlowModal.vue +jeecgboot-vue3/src/views/approval/flow/components/flowTypes.ts +jeecgboot-vue3/src/views/approval/flow/components/FlowNode.vue +jeecgboot-vue3/src/views/approval/flow/components/NodeConfigDrawer.vue +jeecgboot-vue3/src/views/approval/flow/components/FlowDesign.vue +jeecgboot-vue3/src/views/approval/flow/components/flow.less + +-- author:GHT---date:20260529--for: 【QH-MES审批流设计】发起审批运行时:全局悬浮按钮(选单据类型->选单据->发起),本期仅发起(生成审批实例+解析首节点处理人),不办理/不回写业务表 --- +jeecg-boot/jeecg-module-system/jeecg-system-start/src/main/resources/flyway/sql/mysql/V3.9.2_112__mes_xsl_approval_instance.sql +jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/entity/MesXslApprovalFlow.java +jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/entity/MesXslApprovalInstance.java +jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/mapper/MesXslApprovalInstanceMapper.java +jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/service/IMesXslApprovalInstanceService.java +jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/service/impl/MesXslApprovalInstanceServiceImpl.java +jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/controller/MesXslApprovalLaunchController.java +jeecgboot-vue3/src/views/approval/flow/approvalFlow.data.ts +jeecgboot-vue3/src/views/approval/flow/launch.api.ts +jeecgboot-vue3/src/components/ApprovalLaunch/index.vue +jeecgboot-vue3/src/layouts/default/index.vue + +-- author:GHT---date:20260529--for: 【QH-MES审批流设计】发起审批悬浮按钮仅在配置了审批流的功能页显示:审批流定义增加route_path(功能页路由),前端按当前路由匹配后才显示按钮 --- +jeecg-boot/jeecg-module-system/jeecg-system-start/src/main/resources/flyway/sql/mysql/V3.9.2_113__mes_xsl_approval_flow_route.sql +jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/entity/MesXslApprovalFlow.java +jeecgboot-vue3/src/views/approval/flow/approvalFlow.data.ts +jeecgboot-vue3/src/components/ApprovalLaunch/index.vue + +-- author:GHT---date:20260529--for: 【QH-MES审批流设计】取消手填功能页路由:publishedList按单据表名自动反查sys_permission菜单url填入routePath,设计表单去掉路由字段 --- +jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/controller/MesXslApprovalLaunchController.java +jeecgboot-vue3/src/views/approval/flow/approvalFlow.data.ts + +-- author:GHT---date:20260529--for: 【QH-MES审批流设计】发起审批支持列表多选联动:useListPage自动同步选中行到全局上下文,悬浮按钮发起弹窗自动带入选中单据并批量发起 --- +jeecgboot-vue3/src/components/ApprovalLaunch/useApprovalSelection.ts +jeecgboot-vue3/src/hooks/system/useListPage.ts +jeecgboot-vue3/src/components/ApprovalLaunch/index.vue +jeecgboot-vue3/src/views/approval/flow/launch.api.ts +jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/controller/MesXslApprovalLaunchController.java + +-- author:GHT---date:20260529--for: 【QH-MES审批流设计】发起审批与IM聊天结合:IM新增系统单聊消息(绕过同部门校验),发起后把审批消息发给当前节点处理人 --- +jeecg-boot/jeecg-module-system/jeecg-system-biz/src/main/java/org/jeecg/modules/im/service/ISysImChatService.java +jeecg-boot/jeecg-module-system/jeecg-system-biz/src/main/java/org/jeecg/modules/im/service/impl/SysImChatServiceImpl.java +jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/controller/MesXslApprovalLaunchController.java + +-- author:GHT---date:20260529--for: 【QH-MES审批流设计】审批IM消息升级为可跳转业务卡片(biz_record):点击可定位到对应单据,无法定位功能页时退回纯文本 --- +jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/controller/MesXslApprovalLaunchController.java diff --git a/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/callback/ApprovalActionEvent.java b/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/callback/ApprovalActionEvent.java new file mode 100644 index 0000000..ab50bec --- /dev/null +++ b/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/callback/ApprovalActionEvent.java @@ -0,0 +1,27 @@ +package org.jeecg.modules.xslmes.approval.callback; + +import org.springframework.context.ApplicationEvent; + +/** + * 审批动作领域事件。 + * 与 {@link IApprovalBizCallback} 等价的另一种接入方式: + * 偏好松耦合的业务可使用 {@code @EventListener} 监听本事件(同步、同事务)。 + * + * @author GHT + * @date 2026-05-29 for:【QH-MES审批流设计】审批与业务单据联动回调 + */ +public class ApprovalActionEvent extends ApplicationEvent { + + private static final long serialVersionUID = 1L; + + private final ApprovalCallbackContext context; + + public ApprovalActionEvent(Object source, ApprovalCallbackContext context) { + super(source); + this.context = context; + } + + public ApprovalCallbackContext getContext() { + return context; + } +} diff --git a/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/callback/ApprovalActionHttpExecutor.java b/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/callback/ApprovalActionHttpExecutor.java new file mode 100644 index 0000000..ace8db7 --- /dev/null +++ b/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/callback/ApprovalActionHttpExecutor.java @@ -0,0 +1,169 @@ +package org.jeecg.modules.xslmes.approval.callback; + +import com.alibaba.fastjson2.JSONArray; +import com.alibaba.fastjson2.JSONObject; +import lombok.extern.slf4j.Slf4j; +import org.jeecg.common.constant.CommonConstant; +import org.jeecg.common.util.oConvertUtils; +import org.jeecg.modules.xslmes.approval.entity.MesXslApprovalInstance; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.stereotype.Component; +import org.springframework.web.client.RestTemplate; +import org.springframework.web.context.request.RequestContextHolder; +import org.springframework.web.context.request.ServletRequestAttributes; + +import jakarta.servlet.http.HttpServletRequest; + +/** + * 审批节点「回调接口」HTTP 执行器。 + * + * 审批到对应时机时,读取节点配置中录制好的业务接口(url+method), + * 以「当前审批处理人」的登录态(透传当前请求的 X-Access-Token)内部调用该接口, + * 自动带上单据ID(覆盖 id 参数),从而真实执行业务页面按钮背后的逻辑。 + * + * 限制:自动流转 / 无人值守节点(无登录态请求上下文)时降级跳过并记录日志。 + * + * @author GHT + * @date 2026-05-29 for:【QH-MES审批流设计】节点回调接口内部调用执行 + */ +@Slf4j +@Component +public class ApprovalActionHttpExecutor { + + @Value("${server.port:8080}") + private int serverPort; + + @Value("${server.servlet.context-path:}") + private String contextPath; + + private final RestTemplate restTemplate = new RestTemplate(); + + /** + * 执行某节点在指定时机配置的所有回调接口。 + * + * @param node 流程节点 JSON(含 props.callbackActions) + * @param phase 时机:onNodeApprove / onApprove / onReject + * @param inst 审批实例 + */ + public void run(JSONObject node, String phase, MesXslApprovalInstance inst) { + if (node == null || inst == null) { + return; + } + JSONObject propsObj = node.getJSONObject("props"); + if (propsObj == null) { + return; + } + JSONObject callbackActions = propsObj.getJSONObject("callbackActions"); + if (callbackActions == null) { + return; + } + JSONArray actions = callbackActions.getJSONArray(phase); + if (actions == null || actions.isEmpty()) { + return; + } + String token = currentToken(); + for (int i = 0; i < actions.size(); i++) { + JSONObject action = actions.getJSONObject(i); + if (action == null) { + continue; + } + String url = action.getString("url"); + if (oConvertUtils.isEmpty(url)) { + continue; + } + if (oConvertUtils.isEmpty(token)) { + // 无登录态(自动流转/无人值守) -> 降级跳过 + log.warn("[审批回调] 无当前处理人登录态,跳过接口调用 phase={}, url={}, bizId={}", phase, url, inst.getBizDataId()); + continue; + } + String method = oConvertUtils.getString(action.getString("method"), "POST").toUpperCase(); + invoke(method, url, action.getJSONObject("body"), inst.getBizDataId(), token); + } + } + + private void invoke(String method, String url, JSONObject recordedBody, String bizDataId, String token) { + String fullUrl = buildFullUrl(url); + HttpMethod httpMethod = HttpMethod.valueOf(method); + + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + headers.add(CommonConstant.X_ACCESS_TOKEN, token); + headers.add(HttpHeaders.AUTHORIZATION, token); + + Object bodyToSend = null; + if ("GET".equals(method) || "DELETE".equals(method)) { + // 查询/删除:单据ID拼到 url + String sep = fullUrl.contains("?") ? "&" : "?"; + fullUrl = fullUrl + sep + "id=" + bizDataId + "&dataId=" + bizDataId; + } else { + // 写操作:合并录制的 body,并用单据ID覆盖 id + JSONObject body = recordedBody == null ? new JSONObject() : new JSONObject(recordedBody); + body.put("id", bizDataId); + bodyToSend = body; + } + + try { + HttpEntity entity = new HttpEntity<>(bodyToSend, headers); + ResponseEntity resp = restTemplate.exchange(fullUrl, httpMethod, entity, String.class); + String respBody = resp.getBody(); + if (!resp.getStatusCode().is2xxSuccessful()) { + throw new RuntimeException("回调接口返回非2xx:" + resp.getStatusCode()); + } + // Jeecg 统一返回 {success:false,...} 视为业务失败 + if (oConvertUtils.isNotEmpty(respBody)) { + try { + JSONObject r = JSONObject.parseObject(respBody); + if (r != null && r.containsKey("success") && Boolean.FALSE.equals(r.getBoolean("success"))) { + throw new RuntimeException("回调接口业务失败:" + oConvertUtils.getString(r.getString("message"), "未知错误")); + } + } catch (RuntimeException re) { + throw re; + } catch (Exception ignore) { + // 非JSON响应不强校验 + } + } + log.info("[审批回调] 已调用业务接口成功 {} {} bizId={}", method, fullUrl, bizDataId); + } catch (RuntimeException e) { + log.error("[审批回调] 调用业务接口失败 {} {} bizId={}", method, fullUrl, bizDataId, e); + // 抛出以回滚整个审批动作,保证审批与业务一致 + throw e; + } + } + + /** 构建内部调用绝对地址:http://127.0.0.1:port + context-path + url */ + private String buildFullUrl(String url) { + String ctx = oConvertUtils.getString(contextPath, ""); + if (oConvertUtils.isNotEmpty(ctx) && !ctx.startsWith("/")) { + ctx = "/" + ctx; + } + String path = url.startsWith("/") ? url : "/" + url; + // 录制到的路径已含 context-path 时不重复拼接 + if (oConvertUtils.isNotEmpty(ctx) && (path.equals(ctx) || path.startsWith(ctx + "/"))) { + ctx = ""; + } + return "http://127.0.0.1:" + serverPort + ctx + path; + } + + /** 取当前请求的登录 token(处理人身份) */ + private String currentToken() { + try { + ServletRequestAttributes attrs = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes(); + if (attrs == null) { + return null; + } + HttpServletRequest request = attrs.getRequest(); + String token = request.getHeader(CommonConstant.X_ACCESS_TOKEN); + if (oConvertUtils.isEmpty(token)) { + token = request.getHeader(HttpHeaders.AUTHORIZATION); + } + return token; + } catch (Exception e) { + return null; + } + } +} diff --git a/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/callback/ApprovalCallbackContext.java b/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/callback/ApprovalCallbackContext.java new file mode 100644 index 0000000..5b810cd --- /dev/null +++ b/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/callback/ApprovalCallbackContext.java @@ -0,0 +1,80 @@ +package org.jeecg.modules.xslmes.approval.callback; + +import lombok.Data; +import lombok.experimental.Accessors; +import org.jeecg.modules.xslmes.approval.entity.MesXslApprovalInstance; + +import java.io.Serializable; + +/** + * 审批回调上下文。 + * 由审批引擎在「节点通过 / 最终通过 / 驳回」时构建并传给业务回调, + * 业务模块据此调用自身已有的审核/回写接口,实现审批与业务功能的统一联动。 + * + * @author GHT + * @date 2026-05-29 for:【QH-MES审批流设计】审批与业务单据联动回调 + */ +@Data +@Accessors(chain = true) +public class ApprovalCallbackContext implements Serializable { + + private static final long serialVersionUID = 1L; + + /** 回调动作类型 */ + public enum Action { + /** 单个审批节点通过(中间态,每个节点都会触发一次) */ + NODE_APPROVED, + /** 整个流程最终通过 */ + APPROVED, + /** 被驳回(任一节点驳回即终止) */ + REJECTED + } + + /** 回调动作 */ + private Action action; + + /** 审批实例ID */ + private String instanceId; + + /** 审批流定义ID */ + private String flowId; + + /** 审批流名称 */ + private String flowName; + + /** 业务单据表名 */ + private String bizTable; + + /** 业务单据中文名 */ + private String bizTableName; + + /** 业务单据记录ID(业务表主键) */ + private String bizDataId; + + /** 业务单据展示标题 */ + private String bizTitle; + + /** 当前/刚处理的节点ID */ + private String nodeId; + + /** 当前/刚处理的节点名称 */ + private String nodeName; + + /** 操作人 username(系统自动处理时为 null/system) */ + private String operatorUsername; + + /** 操作人姓名 */ + private String operatorName; + + /** 审批意见 / 驳回理由 */ + private String comment; + + /** 发起人 username */ + private String applyUser; + + /** 是否为流程最终结束(APPROVED/REJECTED 时为 true) */ + private boolean finalResult; + + /** 完整审批实例(供业务读取租户、发起信息等) */ + private transient MesXslApprovalInstance instance; +} diff --git a/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/callback/ApprovalCallbackDispatcher.java b/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/callback/ApprovalCallbackDispatcher.java new file mode 100644 index 0000000..8e5b055 --- /dev/null +++ b/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/callback/ApprovalCallbackDispatcher.java @@ -0,0 +1,115 @@ +package org.jeecg.modules.xslmes.approval.callback; + +import lombok.extern.slf4j.Slf4j; +import org.jeecg.common.util.oConvertUtils; +import org.springframework.beans.factory.ObjectProvider; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.stereotype.Component; + +import java.util.ArrayList; +import java.util.List; + +/** + * 审批业务回调分发器。 + * 统一在审批引擎流转关键点调用: + *
    + *
  1. 按业务表名路由到对应的 {@link IApprovalBizCallback} 实现(强类型,业务直接调用自身接口);
  2. + *
  3. 同时发布 {@link ApprovalActionEvent} 供 {@code @EventListener} 松耦合监听。
  4. + *
+ * 回调与审批状态变更同事务执行,回调异常将向上抛出以回滚整个审批动作。 + * + * @author GHT + * @date 2026-05-29 for:【QH-MES审批流设计】审批与业务单据联动回调 + */ +@Slf4j +@Component +public class ApprovalCallbackDispatcher { + + /** 监听所有业务表的通配符 */ + private static final String ANY_TABLE = "*"; + + private final ObjectProvider> callbacksProvider; + private final ApplicationEventPublisher eventPublisher; + + public ApprovalCallbackDispatcher(ObjectProvider> callbacksProvider, + ApplicationEventPublisher eventPublisher) { + this.callbacksProvider = callbacksProvider; + this.eventPublisher = eventPublisher; + } + + /** 节点通过(中间态) */ + public void fireNodeApproved(ApprovalCallbackContext ctx) { + ctx.setAction(ApprovalCallbackContext.Action.NODE_APPROVED); + ctx.setFinalResult(false); + dispatch(ctx); + } + + /** 流程最终通过 */ + public void fireApproved(ApprovalCallbackContext ctx) { + ctx.setAction(ApprovalCallbackContext.Action.APPROVED); + ctx.setFinalResult(true); + dispatch(ctx); + } + + /** 驳回 */ + public void fireRejected(ApprovalCallbackContext ctx) { + ctx.setAction(ApprovalCallbackContext.Action.REJECTED); + ctx.setFinalResult(true); + dispatch(ctx); + } + + private void dispatch(ApprovalCallbackContext ctx) { + if (ctx == null || oConvertUtils.isEmpty(ctx.getBizTable())) { + return; + } + // 1) 强类型回调:按表路由 + 通配 + for (IApprovalBizCallback cb : matchedCallbacks(ctx.getBizTable())) { + invoke(cb, ctx); + } + // 2) 领域事件:松耦合监听(同步、同事务) + try { + eventPublisher.publishEvent(new ApprovalActionEvent(this, ctx)); + } catch (RuntimeException e) { + log.error("审批领域事件处理失败 table={}, bizId={}, action={}", ctx.getBizTable(), ctx.getBizDataId(), ctx.getAction(), e); + throw e; + } + } + + private List matchedCallbacks(String bizTable) { + List all = callbacksProvider.getIfAvailable(); + List matched = new ArrayList<>(); + if (all == null) { + return matched; + } + for (IApprovalBizCallback cb : all) { + String support = cb.supportTable(); + if (ANY_TABLE.equals(support) || (support != null && support.equalsIgnoreCase(bizTable))) { + matched.add(cb); + } + } + return matched; + } + + private void invoke(IApprovalBizCallback cb, ApprovalCallbackContext ctx) { + try { + switch (ctx.getAction()) { + case NODE_APPROVED: + cb.onNodeApproved(ctx); + break; + case APPROVED: + cb.onApproved(ctx); + break; + case REJECTED: + cb.onRejected(ctx); + break; + default: + break; + } + } catch (RuntimeException e) { + log.error("审批业务回调执行失败 callback={}, table={}, bizId={}, action={}", + cb.getClass().getSimpleName(), ctx.getBizTable(), ctx.getBizDataId(), ctx.getAction(), e); + // 抛出以回滚整个审批动作,保证审批与业务数据一致 + throw e; + } + } +} diff --git a/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/callback/IApprovalBizCallback.java b/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/callback/IApprovalBizCallback.java new file mode 100644 index 0000000..185cf5a --- /dev/null +++ b/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/callback/IApprovalBizCallback.java @@ -0,0 +1,64 @@ +package org.jeecg.modules.xslmes.approval.callback; + +/** + * 业务审批回调扩展点(SPI)。 + * + * 各业务模块按需实现本接口并声明 {@link #supportTable()}(绑定的业务表名), + * 审批引擎会在审批流转的关键节点自动回调对应实现,业务模块在回调里 + * 调用自己「已有的」审核/回写接口(如更新审核状态、扣减库存、生成下游单据等), + * 从而把审批流程与业务单据的功能统一串联起来。 + * + *

事务说明:回调与审批状态变更处于同一事务内, + * 若回调抛出异常,整个审批动作回滚(审批失败),保证审批与业务数据一致。

+ * + *

使用方式:实现类标注为 Spring Bean(@Component / @Service)即可被自动收集。

+ * + *
{@code
+ * @Component
+ * public class RubberStdApprovalCallback implements IApprovalBizCallback {
+ *     @Override public String supportTable() { return "mes_xsl_rubber_quick_test_std"; }
+ *     @Override public void onApproved(ApprovalCallbackContext ctx) {
+ *         // 调用业务自身已有接口完成回写
+ *         stdService.lambdaUpdate()
+ *             .eq(MesXslRubberQuickTestStd::getId, ctx.getBizDataId())
+ *             .set(MesXslRubberQuickTestStd::getAuditStatus, "1").update();
+ *     }
+ * }
+ * }
+ * + * @author GHT + * @date 2026-05-29 for:【QH-MES审批流设计】审批与业务单据联动回调 + */ +public interface IApprovalBizCallback { + + /** + * 绑定的业务表名(与审批流定义 bizTable 一致)。 + * 返回 "*" 表示监听所有业务表。 + */ + String supportTable(); + + /** + * 单个审批节点通过(中间态)。每经过一个审批节点通过都会触发一次。 + * 适合更新中间状态(如「审核中」「已校对」等)。 + * 默认空实现,业务按需重写。 + */ + default void onNodeApproved(ApprovalCallbackContext ctx) { + // 默认不处理 + } + + /** + * 整个审批流程最终通过。适合执行终态业务(如置为「已批准」、生效、扣库存等)。 + * 默认空实现,业务按需重写。 + */ + default void onApproved(ApprovalCallbackContext ctx) { + // 默认不处理 + } + + /** + * 审批被驳回(流程终止)。适合回退业务状态(如置回「草稿」、释放占用等)。 + * 默认空实现,业务按需重写。 + */ + default void onRejected(ApprovalCallbackContext ctx) { + // 默认不处理 + } +} diff --git a/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/callback/impl/RubberQuickTestStdApprovalCallback.java b/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/callback/impl/RubberQuickTestStdApprovalCallback.java new file mode 100644 index 0000000..303b13f --- /dev/null +++ b/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/callback/impl/RubberQuickTestStdApprovalCallback.java @@ -0,0 +1,59 @@ +package org.jeecg.modules.xslmes.approval.callback.impl; + +import lombok.extern.slf4j.Slf4j; +import org.jeecg.common.util.oConvertUtils; +import org.jeecg.modules.xslmes.approval.callback.ApprovalCallbackContext; +import org.jeecg.modules.xslmes.approval.callback.IApprovalBizCallback; +import org.jeecg.modules.xslmes.common.XslMesBizConstants; +import org.jeecg.modules.xslmes.entity.MesXslRubberQuickTestStd; +import org.jeecg.modules.xslmes.service.IMesXslRubberQuickTestStdService; +import org.springframework.stereotype.Component; + +/** + * 胶料快检实验标准 审批回调示例。 + * 演示如何把审批流转结果联动到业务单据已有功能: + * 审批最终通过 -> 审核状态置「已批准」;驳回 -> 回退「草稿」。 + * + * 业务在回调里直接调用自身的 service(此处用 lambdaUpdate 更新审核状态字段), + * 与原有「批准/反审核」逻辑保持统一。 + * + * @author GHT + * @date 2026-05-29 for:【QH-MES审批流设计】审批与业务单据联动回调-示例 + */ +@Slf4j +@Component +public class RubberQuickTestStdApprovalCallback implements IApprovalBizCallback { + + private final IMesXslRubberQuickTestStdService stdService; + + public RubberQuickTestStdApprovalCallback(IMesXslRubberQuickTestStdService stdService) { + this.stdService = stdService; + } + + @Override + public String supportTable() { + return "mes_xsl_rubber_quick_test_std"; + } + + @Override + public void onApproved(ApprovalCallbackContext ctx) { + updateAuditStatus(ctx.getBizDataId(), XslMesBizConstants.RUBBER_QUICK_TEST_STD_AUDIT_APPROVED); + log.info("[审批联动] 实验标准 {} 审批通过,审核状态置为已批准", ctx.getBizDataId()); + } + + @Override + public void onRejected(ApprovalCallbackContext ctx) { + updateAuditStatus(ctx.getBizDataId(), XslMesBizConstants.RUBBER_QUICK_TEST_STD_AUDIT_DRAFT); + log.info("[审批联动] 实验标准 {} 被驳回,审核状态回退为草稿", ctx.getBizDataId()); + } + + private void updateAuditStatus(String bizDataId, String auditStatus) { + if (oConvertUtils.isEmpty(bizDataId)) { + return; + } + stdService.lambdaUpdate() + .eq(MesXslRubberQuickTestStd::getId, bizDataId) + .set(MesXslRubberQuickTestStd::getAuditStatus, auditStatus) + .update(); + } +} diff --git a/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/controller/MesXslApprovalFlowController.java b/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/controller/MesXslApprovalFlowController.java new file mode 100644 index 0000000..80ebc07 --- /dev/null +++ b/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/controller/MesXslApprovalFlowController.java @@ -0,0 +1,433 @@ +package org.jeecg.modules.xslmes.approval.controller; + +import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper; +import com.baomidou.mybatisplus.core.metadata.IPage; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.servlet.http.HttpServletRequest; +import lombok.extern.slf4j.Slf4j; +import org.apache.shiro.authz.annotation.RequiresPermissions; +import org.jeecg.common.api.vo.Result; +import org.jeecg.common.aspect.annotation.AutoLog; +import org.jeecg.common.system.api.ISysBaseAPI; +import org.jeecg.common.system.base.controller.JeecgController; +import org.jeecg.common.system.query.QueryGenerator; +import org.jeecg.common.system.vo.DictModel; +import org.jeecg.common.util.oConvertUtils; +import org.jeecg.modules.xslmes.approval.entity.MesXslApprovalFlow; +import org.jeecg.modules.xslmes.approval.service.IMesXslApprovalFlowService; +import org.jeecg.modules.xslmes.common.MesXslTenantUtils; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.web.bind.annotation.*; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.regex.Pattern; + +/** + * MES 审批流设计 + * + * @author GHT + * @date 2026-05-29 for:【QH-MES审批流设计】新增审批流可视化设计 + */ +@Tag(name = "MES审批流设计") +@RestController +@RequestMapping("/xslmes/approvalFlow") +@Slf4j +public class MesXslApprovalFlowController extends JeecgController { + + @Autowired + private IMesXslApprovalFlowService mesXslApprovalFlowService; + + @Autowired + private ISysBaseAPI sysBaseAPI; + + //update-begin---author:GHT ---date:2026-05-29 for:【QH-MES审批流设计】当前页字段解析----- + @Autowired + private JdbcTemplate jdbcTemplate; + + /** 合法标识符(表名/字段名)白名单校验,防 SQL 注入 */ + private static final Pattern IDENTIFIER = Pattern.compile("^[A-Za-z0-9_]+$"); + + /** + * 审批阶段关键字配置(有序):key=阶段标识,name=阶段中文,nodeType=对应节点类型,keywords=列注释匹配关键字。 + * 解析顺序即默认流程顺序:校对 -> 审核 -> 审批 -> 分发 -> 抄送。 + */ + private static final String[][] STAGE_DEFS = new String[][]{ + {"proofread", "校对", "approver", "校对"}, + {"review", "审核", "approver", "审核|审查"}, + {"approve", "审批", "approver", "审批|批准|核准"}, + {"distribute", "分发", "approver", "分发|发放"}, + {"cc", "抄送", "cc", "抄送"}, + }; + //update-end---author:GHT ---date:2026-05-29 for:【QH-MES审批流设计】当前页字段解析----- + + /** + * 根据所选单据表名翻译字典,回填单据中文名 + */ + private void fillBizTableName(MesXslApprovalFlow flow) { + if (oConvertUtils.isEmpty(flow.getBizTable())) { + return; + } + List items = sysBaseAPI.getDictItems("mes_xsl_approval_biz_doc"); + if (items != null) { + for (DictModel item : items) { + if (flow.getBizTable().equals(item.getValue())) { + flow.setBizTableName(item.getText()); + break; + } + } + } + } + + @Operation(summary = "审批流设计-分页列表查询") + @RequiresPermissions("approval:flow:list") + @GetMapping(value = "/list") + public Result> queryPageList( + MesXslApprovalFlow mesXslApprovalFlow, + @RequestParam(name = "pageNo", defaultValue = "1") Integer pageNo, + @RequestParam(name = "pageSize", defaultValue = "10") Integer pageSize, + HttpServletRequest req) { + QueryWrapper queryWrapper = QueryGenerator.initQueryWrapper(mesXslApprovalFlow, req.getParameterMap()); + // 列表查询不返回大字段 flowConfig,避免传输冗余 + queryWrapper.select(MesXslApprovalFlow.class, info -> !"flow_config".equals(info.getColumn())); + queryWrapper.orderByDesc("create_time"); + Page page = new Page<>(pageNo, pageSize); + IPage pageList = mesXslApprovalFlowService.page(page, queryWrapper); + return Result.OK(pageList); + } + + @AutoLog(value = "审批流设计-添加") + @Operation(summary = "审批流设计-添加") + @RequiresPermissions("approval:flow:add") + @PostMapping(value = "/add") + public Result add(@RequestBody MesXslApprovalFlow mesXslApprovalFlow) { + if (oConvertUtils.isEmpty(mesXslApprovalFlow.getFlowName())) { + return Result.error("审批流名称不能为空"); + } + if (oConvertUtils.isEmpty(mesXslApprovalFlow.getBizTable())) { + return Result.error("请先选择绑定单据"); + } + if (oConvertUtils.isEmpty(mesXslApprovalFlow.getStatus())) { + mesXslApprovalFlow.setStatus("0"); + } + fillBizTableName(mesXslApprovalFlow); + mesXslApprovalFlowService.save(mesXslApprovalFlow); + return Result.OK("添加成功!"); + } + + @AutoLog(value = "审批流设计-编辑") + @Operation(summary = "审批流设计-编辑") + @RequiresPermissions("approval:flow:edit") + @RequestMapping(value = "/edit", method = {RequestMethod.PUT, RequestMethod.POST}) + public Result edit(@RequestBody MesXslApprovalFlow mesXslApprovalFlow) { + if (oConvertUtils.isEmpty(mesXslApprovalFlow.getId())) { + return Result.error("主键不能为空"); + } + fillBizTableName(mesXslApprovalFlow); + mesXslApprovalFlowService.updateById(mesXslApprovalFlow); + return Result.OK("编辑成功!"); + } + + @AutoLog(value = "审批流设计-保存流程设计") + @Operation(summary = "审批流设计-保存流程设计") + @RequiresPermissions("approval:flow:design") + @PostMapping(value = "/saveDesign") + public Result saveDesign(@RequestBody MesXslApprovalFlow mesXslApprovalFlow) { + if (oConvertUtils.isEmpty(mesXslApprovalFlow.getId())) { + return Result.error("主键不能为空"); + } + MesXslApprovalFlow update = new MesXslApprovalFlow(); + update.setId(mesXslApprovalFlow.getId()); + update.setFlowConfig(mesXslApprovalFlow.getFlowConfig()); + // 设计保存时若仍为草稿则置为已发布 + if (oConvertUtils.isNotEmpty(mesXslApprovalFlow.getStatus())) { + update.setStatus(mesXslApprovalFlow.getStatus()); + } + mesXslApprovalFlowService.updateById(update); + return Result.OK("流程设计已保存!"); + } + + @AutoLog(value = "审批流设计-发布/停用") + @Operation(summary = "审批流设计-发布/停用") + @RequiresPermissions("approval:flow:design") + @PostMapping(value = "/updateStatus") + public Result updateStatus( + @RequestParam(name = "id") String id, + @RequestParam(name = "status") String status) { + if (!"0".equals(status) && !"1".equals(status) && !"2".equals(status)) { + return Result.error("状态参数非法"); + } + boolean ok = mesXslApprovalFlowService.lambdaUpdate() + .eq(MesXslApprovalFlow::getId, id) + .set(MesXslApprovalFlow::getStatus, status) + .update(); + return ok ? Result.OK("操作成功") : Result.error("操作失败"); + } + + @AutoLog(value = "审批流设计-删除") + @Operation(summary = "审批流设计-删除") + @RequiresPermissions("approval:flow:delete") + @DeleteMapping(value = "/delete") + public Result delete(@RequestParam(name = "id") String id) { + mesXslApprovalFlowService.removeById(id); + return Result.OK("删除成功!"); + } + + @AutoLog(value = "审批流设计-批量删除") + @Operation(summary = "审批流设计-批量删除") + @RequiresPermissions("approval:flow:delete") + @DeleteMapping(value = "/deleteBatch") + public Result deleteBatch(@RequestParam(name = "ids") String ids) { + mesXslApprovalFlowService.removeByIds(Arrays.asList(ids.split(","))); + return Result.OK("批量删除成功!"); + } + + @Operation(summary = "审批流设计-通过id查询") + @GetMapping(value = "/queryById") + public Result queryById(@RequestParam(name = "id") String id) { + MesXslApprovalFlow entity = mesXslApprovalFlowService.getById(id); + if (entity == null) { + return Result.error("未找到对应数据"); + } + return Result.OK(entity); + } + + //update-begin---author:GHT ---date:2026-05-29 for:【QH-MES审批流设计】全局审批流程设计悬浮按钮-当前页字段解析----- + /** + * 设计上下文:供全局"审批流程设计"悬浮按钮调用。 + * 1) 根据当前功能页路由反查绑定的业务表; + * 2) 解析该表的字段,识别"校对/审核/审批/分发/抄送"等阶段字段(不存在不报错,存在即解析); + * 3) 取/建该业务表的草稿审批流,返回流程记录(含id)供直接进入设计器。 + * + * @param routePath 当前功能页前端路由(如 /xslmes/mesXslFormulaSpec/MesXslFormulaSpecList) + */ + @Operation(summary = "审批流设计-当前页设计上下文") + @RequiresPermissions("approval:flow:design") + @GetMapping(value = "/designContext") + public Result> designContext(@RequestParam(name = "routePath") String routePath) { + Map data = new LinkedHashMap<>(); + data.put("routePath", routePath); + data.put("bizTable", null); + data.put("bizTableName", null); + data.put("stages", new ArrayList<>()); + data.put("flow", null); + + // 1) 路由 -> 业务表名(无法反查时不报错,返回空上下文,前端提示) + String table = resolveTableByRoutePath(routePath); + if (oConvertUtils.isEmpty(table) || !tableExists(table)) { + return Result.OK(data); + } + data.put("bizTable", table); + + // 2) 业务表中文名:优先字典,其次表注释 + String bizTableName = resolveBizTableName(table); + data.put("bizTableName", bizTableName); + + // 3) 解析阶段字段 + data.put("stages", parseStageFields(table)); + + // 4) 取/建草稿审批流 + Integer tenantId = MesXslTenantUtils.resolveTenantId(null); + MesXslApprovalFlow flow = findFlowByTable(table, tenantId); + if (flow == null) { + flow = new MesXslApprovalFlow(); + flow.setFlowName((oConvertUtils.isNotEmpty(bizTableName) ? bizTableName : table) + "审批流"); + flow.setBizTable(table); + flow.setBizTableName(bizTableName); + flow.setRoutePath(routePath); + flow.setStatus("0"); + if (tenantId != null) { + flow.setTenantId(tenantId); + } + mesXslApprovalFlowService.save(flow); + } else if (oConvertUtils.isEmpty(flow.getRoutePath())) { + // 历史数据未记录路由,回填一次便于后续发起按钮匹配 + mesXslApprovalFlowService.lambdaUpdate() + .eq(MesXslApprovalFlow::getId, flow.getId()) + .set(MesXslApprovalFlow::getRoutePath, routePath) + .update(); + flow.setRoutePath(routePath); + } + data.put("flow", flow); + return Result.OK(data); + } + + /** + * 根据前端路由反查业务表名。 + * 约定:jeecg 代码生成的列表组件名为 表名驼峰 + List,sys_permission.component 形如 + * xslmes/mesXslFormulaSpec/MesXslFormulaSpecList,url 即路由。 + * 反查:url=routePath -> component -> 末段组件名去掉 List -> 驼峰转下划线得到表名。 + */ + private String resolveTableByRoutePath(String routePath) { + if (oConvertUtils.isEmpty(routePath)) { + return null; + } + String path = routePath.trim().replaceAll("/+$", ""); + String sql = "SELECT component FROM sys_permission WHERE menu_type IN (0,1) " + + "AND (del_flag = 0 OR del_flag IS NULL) AND url = ? " + + "ORDER BY menu_type DESC LIMIT 1"; + String component; + try { + List list = jdbcTemplate.queryForList(sql, String.class, path); + if (list.isEmpty()) { + return null; + } + component = list.get(0); + } catch (Exception e) { + log.warn("反查菜单组件失败 routePath={}", routePath, e); + return null; + } + if (oConvertUtils.isEmpty(component)) { + return null; + } + // 取组件路径末段:xslmes/mesXslFormulaSpec/MesXslFormulaSpecList -> MesXslFormulaSpecList + String comp = component.contains("/") ? component.substring(component.lastIndexOf('/') + 1) : component; + if (comp.endsWith("List")) { + comp = comp.substring(0, comp.length() - "List".length()); + } + return camelToUnderline(comp); + } + + /** 驼峰转下划线小写:MesXslFormulaSpec -> mes_xsl_formula_spec */ + private String camelToUnderline(String camel) { + if (oConvertUtils.isEmpty(camel)) { + return null; + } + StringBuilder sb = new StringBuilder(); + for (int i = 0; i < camel.length(); i++) { + char c = camel.charAt(i); + if (Character.isUpperCase(c)) { + if (i > 0) { + sb.append('_'); + } + sb.append(Character.toLowerCase(c)); + } else { + sb.append(c); + } + } + return sb.toString(); + } + + /** 校验表是否存在于当前库 */ + private boolean tableExists(String table) { + if (!IDENTIFIER.matcher(table).matches()) { + return false; + } + try { + Integer cnt = jdbcTemplate.queryForObject( + "SELECT COUNT(1) FROM information_schema.tables WHERE table_schema = (SELECT DATABASE()) AND table_name = ?", + Integer.class, table); + return cnt != null && cnt > 0; + } catch (Exception e) { + log.warn("校验表存在失败 table={}", table, e); + return false; + } + } + + /** 业务表中文名:优先字典 mes_xsl_approval_biz_doc,其次表注释 */ + private String resolveBizTableName(String table) { + List items = sysBaseAPI.getDictItems("mes_xsl_approval_biz_doc"); + if (items != null) { + for (DictModel item : items) { + if (table.equals(item.getValue())) { + return item.getText(); + } + } + } + try { + List comments = jdbcTemplate.queryForList( + "SELECT table_comment FROM information_schema.tables WHERE table_schema = (SELECT DATABASE()) AND table_name = ?", + String.class, table); + if (!comments.isEmpty() && oConvertUtils.isNotEmpty(comments.get(0))) { + return comments.get(0); + } + } catch (Exception e) { + log.warn("查询表注释失败 table={}", table, e); + } + return null; + } + + /** + * 解析表字段,识别审批阶段字段。每个阶段最多取一个字段(优先列注释含"人/员"的人员字段)。 + * 返回有序列表:[{stageKey, stageName, nodeType, field, fieldComment}] + */ + private List> parseStageFields(String table) { + List> stages = new ArrayList<>(); + List> columns; + try { + columns = jdbcTemplate.queryForList( + "SELECT column_name AS name, column_comment AS comment FROM information_schema.columns " + + "WHERE table_schema = (SELECT DATABASE()) AND table_name = ? ORDER BY ordinal_position", + table); + } catch (Exception e) { + log.warn("查询表字段失败 table={}", table, e); + return stages; + } + for (String[] def : STAGE_DEFS) { + String stageKey = def[0]; + String stageName = def[1]; + String nodeType = def[2]; + String[] keywords = def[3].split("\\|"); + Map hit = matchStageColumn(columns, keywords); + if (hit != null) { + Map stage = new LinkedHashMap<>(); + stage.put("stageKey", stageKey); + stage.put("stageName", stageName); + stage.put("nodeType", nodeType); + stage.put("field", hit.get("name")); + stage.put("fieldComment", hit.get("comment")); + stages.add(stage); + } + } + return stages; + } + + /** 在列集合中按关键字匹配阶段字段,优先返回注释含"人/员"的人员字段 */ + private Map matchStageColumn(List> columns, String[] keywords) { + Map firstMatch = null; + for (Map col : columns) { + String comment = col.get("comment") == null ? "" : String.valueOf(col.get("comment")); + if (oConvertUtils.isEmpty(comment)) { + continue; + } + boolean matched = false; + for (String kw : keywords) { + if (comment.contains(kw)) { + matched = true; + break; + } + } + if (!matched) { + continue; + } + if (firstMatch == null) { + firstMatch = col; + } + // 人员字段优先(如"校对人""审核员") + if (comment.contains("人") || comment.contains("员")) { + return col; + } + } + return firstMatch; + } + + /** 按业务表+租户查找审批流(取最近一条) */ + private MesXslApprovalFlow findFlowByTable(String table, Integer tenantId) { + QueryWrapper qw = new QueryWrapper<>(); + qw.eq("biz_table", table); + if (tenantId != null) { + qw.eq("tenant_id", tenantId); + } + qw.orderByDesc("create_time"); + qw.last("LIMIT 1"); + return mesXslApprovalFlowService.getOne(qw, false); + } + //update-end---author:GHT ---date:2026-05-29 for:【QH-MES审批流设计】全局审批流程设计悬浮按钮-当前页字段解析----- + +} diff --git a/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/controller/MesXslApprovalHandleController.java b/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/controller/MesXslApprovalHandleController.java new file mode 100644 index 0000000..2a57020 --- /dev/null +++ b/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/controller/MesXslApprovalHandleController.java @@ -0,0 +1,74 @@ +package org.jeecg.modules.xslmes.approval.controller; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.extern.slf4j.Slf4j; +import org.apache.shiro.SecurityUtils; +import org.jeecg.common.api.vo.Result; +import org.jeecg.common.system.vo.LoginUser; +import org.jeecg.common.util.oConvertUtils; +import org.jeecg.modules.xslmes.approval.service.IMesXslApprovalHandleService; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.web.bind.annotation.*; + +import java.util.Map; + +/** + * MES 审批办理(运行时-流转)。 + * 供 IM 审批卡片按钮调用:查看详情 / 审批通过 / 驳回。 + * + * @author GHT + * @date 2026-05-29 for:【QH-MES审批流设计】审批办理/流转 + */ +@Tag(name = "MES审批办理") +@RestController +@RequestMapping("/xslmes/approvalHandle") +@Slf4j +public class MesXslApprovalHandleController { + + @Autowired + private IMesXslApprovalHandleService approvalHandleService; + + @Operation(summary = "审批办理-单据详情") + @GetMapping("/detail") + public Result> detail(@RequestParam("instanceId") String instanceId) { + LoginUser user = (LoginUser) SecurityUtils.getSubject().getPrincipal(); + Map data = approvalHandleService.detail(instanceId, user); + if (data == null || data.isEmpty()) { + return Result.error("审批实例不存在"); + } + return Result.OK(data); + } + + @Operation(summary = "审批办理-实时状态(卡片置灰判断)") + @GetMapping("/status") + public Result> status(@RequestParam("instanceId") String instanceId) { + LoginUser user = (LoginUser) SecurityUtils.getSubject().getPrincipal(); + Map data = approvalHandleService.statusInfo(instanceId, user); + return Result.OK(data); + } + + @Operation(summary = "审批办理-通过") + @PostMapping("/approve") + public Result approve(@RequestBody Map body) { + String instanceId = (String) body.get("instanceId"); + String comment = body.get("comment") == null ? null : String.valueOf(body.get("comment")); + if (oConvertUtils.isEmpty(instanceId)) { + return Result.error("缺少审批实例ID"); + } + LoginUser user = (LoginUser) SecurityUtils.getSubject().getPrincipal(); + return approvalHandleService.approve(instanceId, comment, user); + } + + @Operation(summary = "审批办理-驳回") + @PostMapping("/reject") + public Result reject(@RequestBody Map body) { + String instanceId = (String) body.get("instanceId"); + String reason = body.get("reason") == null ? null : String.valueOf(body.get("reason")); + if (oConvertUtils.isEmpty(instanceId)) { + return Result.error("缺少审批实例ID"); + } + LoginUser user = (LoginUser) SecurityUtils.getSubject().getPrincipal(); + return approvalHandleService.reject(instanceId, reason, user); + } +} diff --git a/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/controller/MesXslApprovalLaunchController.java b/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/controller/MesXslApprovalLaunchController.java new file mode 100644 index 0000000..9e9e663 --- /dev/null +++ b/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/controller/MesXslApprovalLaunchController.java @@ -0,0 +1,242 @@ +package org.jeecg.modules.xslmes.approval.controller; + +import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.extern.slf4j.Slf4j; +import org.apache.shiro.SecurityUtils; +import org.jeecg.common.api.vo.Result; +import org.jeecg.common.system.vo.LoginUser; +import org.jeecg.common.util.oConvertUtils; +import org.jeecg.modules.xslmes.approval.entity.MesXslApprovalFlow; +import org.jeecg.modules.xslmes.approval.entity.MesXslApprovalInstance; +import org.jeecg.modules.xslmes.approval.service.IMesXslApprovalFlowService; +import org.jeecg.modules.xslmes.approval.service.IMesXslApprovalHandleService; +import org.jeecg.modules.xslmes.approval.service.IMesXslApprovalInstanceService; +import org.jeecg.modules.xslmes.common.MesXslTenantUtils; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.web.bind.annotation.*; + +import java.util.ArrayList; +import java.util.Date; +import java.util.List; +import java.util.Map; +import java.util.regex.Pattern; + +/** + * MES 审批发起(运行时-本期仅发起) + * 供全局"发起审批"悬浮按钮调用:选单据类型 -> 选单据记录 -> 发起。 + * + * @author GHT + * @date 2026-05-29 for:【QH-MES审批流设计】发起审批运行时 + */ +@Tag(name = "MES审批发起") +@RestController +@RequestMapping("/xslmes/approvalLaunch") +@Slf4j +public class MesXslApprovalLaunchController { + + /** 合法标识符(表名/字段名)白名单校验,防 SQL 注入 */ + private static final Pattern IDENTIFIER = Pattern.compile("^[A-Za-z0-9_]+$"); + + @Autowired + private IMesXslApprovalFlowService approvalFlowService; + + @Autowired + private IMesXslApprovalInstanceService approvalInstanceService; + + //update-begin---author:GHT ---date:2026-05-29 for:【QH-MES审批流设计】发起改用流转引擎进入首节点----- + @Autowired + private IMesXslApprovalHandleService approvalHandleService; + //update-end---author:GHT ---date:2026-05-29 for:【QH-MES审批流设计】发起改用流转引擎进入首节点----- + + @Autowired + private JdbcTemplate jdbcTemplate; + + /** + * 已发布审批流列表(按租户隔离),即"可发起的单据类型"。 + * 同时按"功能模块(单据表)"自动反查其菜单路由填入 routePath,供前端控制悬浮按钮仅在该功能页显示,无需手工配置。 + */ + @Operation(summary = "发起审批-已发布审批流列表") + @GetMapping("/publishedList") + public Result> publishedList() { + QueryWrapper qw = new QueryWrapper<>(); + qw.eq("status", "1"); + Integer tenantId = MesXslTenantUtils.resolveTenantId(null); + if (tenantId != null) { + qw.eq("tenant_id", tenantId); + } + qw.orderByDesc("create_time"); + List list = approvalFlowService.list(qw); + // 未手工指定 route_path 时,按单据表名自动反查菜单路由 + for (MesXslApprovalFlow flow : list) { + if (oConvertUtils.isEmpty(flow.getRoutePath())) { + flow.setRoutePath(resolveRoutePathByTable(flow.getBizTable())); + } + } + return Result.OK(list); + } + + /** + * 根据单据表名反查对应功能菜单的前端路由。 + * 约定:jeecg 代码生成的列表组件名为 表名驼峰 + List(如 mes_xsl_formula_spec -> MesXslFormulaSpecList), + * 对应 sys_permission.component 形如 xslmes/mesXslFormulaSpec/MesXslFormulaSpecList,取其 url 即路由。 + */ + private String resolveRoutePathByTable(String table) { + if (oConvertUtils.isEmpty(table) || !IDENTIFIER.matcher(table).matches()) { + return null; + } + StringBuilder comp = new StringBuilder(); + for (String p : table.split("_")) { + if (oConvertUtils.isEmpty(p)) { + continue; + } + comp.append(Character.toUpperCase(p.charAt(0))).append(p.substring(1)); + } + comp.append("List"); + String sql = "SELECT url FROM sys_permission WHERE menu_type IN (0,1) " + + "AND (del_flag = 0 OR del_flag IS NULL) " + + "AND (component LIKE ? OR component LIKE ?) " + + "ORDER BY menu_type DESC LIMIT 1"; + try { + List urls = jdbcTemplate.queryForList(sql, String.class, "%/" + comp, "%" + comp); + return urls.isEmpty() ? null : urls.get(0); + } catch (Exception e) { + log.warn("反查菜单路由失败 table={}", table, e); + return null; + } + } + + /** + * 根据审批流绑定的单据表,查询业务单据记录(id + 标题),供发起时选择 + */ + @Operation(summary = "发起审批-业务单据记录列表") + @GetMapping("/bizRecords") + public Result>> bizRecords( + @RequestParam("flowId") String flowId, + @RequestParam(value = "keyword", required = false) String keyword) { + MesXslApprovalFlow flow = approvalFlowService.getById(flowId); + if (flow == null) { + return Result.error("审批流不存在"); + } + String table = flow.getBizTable(); + String titleField = flow.getTitleField(); + if (oConvertUtils.isEmpty(table) || !IDENTIFIER.matcher(table).matches()) { + return Result.error("单据表名非法"); + } + boolean hasTitle = oConvertUtils.isNotEmpty(titleField) && IDENTIFIER.matcher(titleField).matches(); + + StringBuilder sql = new StringBuilder("SELECT id, "); + sql.append(hasTitle ? titleField : "id").append(" AS title FROM ").append(table); + List args = new ArrayList<>(); + if (hasTitle && oConvertUtils.isNotEmpty(keyword)) { + sql.append(" WHERE ").append(titleField).append(" LIKE CONCAT('%', ?, '%')"); + args.add(keyword); + } + // 主键一定存在,按 id 倒序近似最新;限制条数防全表 + sql.append(" ORDER BY id DESC LIMIT 100"); + try { + List> list = jdbcTemplate.queryForList(sql.toString(), args.toArray()); + return Result.OK(list); + } catch (Exception e) { + log.error("查询业务单据失败 table={}, field={}", table, titleField, e); + return Result.error("查询业务单据失败:" + e.getMessage()); + } + } + + /** + * 发起审批:根据审批流定义创建审批实例,解析首个审批节点处理人 + */ + @Operation(summary = "发起审批-发起") + @PostMapping("/launch") + public Result launch(@RequestBody Map body) { + String flowId = (String) body.get("flowId"); + String bizDataId = (String) body.get("bizDataId"); + String bizTitle = (String) body.get("bizTitle"); + if (oConvertUtils.isEmpty(flowId) || oConvertUtils.isEmpty(bizDataId)) { + return Result.error("请选择审批流和单据"); + } + MesXslApprovalFlow flow = approvalFlowService.getById(flowId); + if (flow == null) { + return Result.error("审批流不存在"); + } + if (!"1".equals(flow.getStatus())) { + return Result.error("该审批流未发布,无法发起"); + } + + LoginUser loginUser = (LoginUser) SecurityUtils.getSubject().getPrincipal(); + //update-begin---author:GHT ---date:2026-05-29 for:【QH-MES审批流设计】发起后由引擎进入首节点(解析处理人/建进度/发可办理卡片)----- + MesXslApprovalInstance inst = buildInstance(flow, bizDataId, bizTitle, loginUser); + approvalInstanceService.save(inst); + approvalHandleService.enterFirstNode(inst, loginUser); + //update-end---author:GHT ---date:2026-05-29 for:【QH-MES审批流设计】发起后由引擎进入首节点(解析处理人/建进度/发可办理卡片)----- + return Result.OK("发起成功!"); + } + + /** + * 批量发起审批:用于列表多选后一次性发起 + */ + @Operation(summary = "发起审批-批量发起") + @PostMapping("/launchBatch") + public Result launchBatch(@RequestBody Map body) { + String flowId = (String) body.get("flowId"); + Object itemsObj = body.get("items"); + if (oConvertUtils.isEmpty(flowId) || !(itemsObj instanceof List) || ((List) itemsObj).isEmpty()) { + return Result.error("请选择审批流和单据"); + } + MesXslApprovalFlow flow = approvalFlowService.getById(flowId); + if (flow == null) { + return Result.error("审批流不存在"); + } + if (!"1".equals(flow.getStatus())) { + return Result.error("该审批流未发布,无法发起"); + } + + LoginUser loginUser = (LoginUser) SecurityUtils.getSubject().getPrincipal(); + //update-begin---author:GHT ---date:2026-05-29 for:【QH-MES审批流设计】批量发起逐条进入首节点(支持按单据字段解析处理人/会签)----- + int count = 0; + for (Object o : (List) itemsObj) { + if (!(o instanceof Map)) { + continue; + } + Map item = (Map) o; + String bizDataId = item.get("bizDataId") == null ? null : String.valueOf(item.get("bizDataId")); + String bizTitle = item.get("bizTitle") == null ? null : String.valueOf(item.get("bizTitle")); + if (oConvertUtils.isEmpty(bizDataId)) { + continue; + } + MesXslApprovalInstance inst = buildInstance(flow, bizDataId, bizTitle, loginUser); + approvalInstanceService.save(inst); + approvalHandleService.enterFirstNode(inst, loginUser); + count++; + } + if (count == 0) { + return Result.error("没有有效的单据数据"); + } + return Result.OK("已发起 " + count + " 条审批!"); + //update-end---author:GHT ---date:2026-05-29 for:【QH-MES审批流设计】批量发起逐条进入首节点(支持按单据字段解析处理人/会签)----- + } + + /** + * 构建一条审批实例(基础字段;处理人解析/卡片由流转引擎完成) + */ + private MesXslApprovalInstance buildInstance(MesXslApprovalFlow flow, String bizDataId, String bizTitle, LoginUser loginUser) { + MesXslApprovalInstance inst = new MesXslApprovalInstance(); + inst.setFlowId(flow.getId()); + inst.setFlowName(flow.getFlowName()); + inst.setBizTable(flow.getBizTable()); + inst.setBizTableName(flow.getBizTableName()); + inst.setBizDataId(bizDataId); + inst.setBizTitle(oConvertUtils.isNotEmpty(bizTitle) ? bizTitle : bizDataId); + inst.setStatus("0"); + if (loginUser != null) { + inst.setApplyUser(loginUser.getUsername()); + inst.setApplyUserName(loginUser.getRealname()); + } + inst.setApplyTime(new Date()); + inst.setTenantId(MesXslTenantUtils.resolveTenantId(flow.getTenantId())); + // 处理人解析、节点进度初始化、卡片发送统一由 IMesXslApprovalHandleService.enterFirstNode 完成 + return inst; + } +} diff --git a/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/entity/MesXslApprovalFlow.java b/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/entity/MesXslApprovalFlow.java new file mode 100644 index 0000000..378c8fc --- /dev/null +++ b/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/entity/MesXslApprovalFlow.java @@ -0,0 +1,68 @@ +package org.jeecg.modules.xslmes.approval.entity; + +import com.baomidou.mybatisplus.annotation.TableLogic; +import com.baomidou.mybatisplus.annotation.TableName; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.experimental.Accessors; +import org.jeecg.common.aspect.annotation.Dict; +import org.jeecg.common.system.base.entity.JeecgEntity; + +import java.io.Serializable; + +/** + * MES 审批流定义 + * 钉钉式可视化审批流设计,绑定 MES 业务单据。 + * + * @author GHT + * @date 2026-05-29 for:【QH-MES审批流设计】新增审批流可视化设计 + */ +@Data +@EqualsAndHashCode(callSuper = false) +@Accessors(chain = true) +@TableName("mes_xsl_approval_flow") +@Schema(description = "MES审批流定义") +public class MesXslApprovalFlow extends JeecgEntity implements Serializable { + + private static final long serialVersionUID = 1L; + + @Schema(description = "审批流名称") + private String flowName; + + @Schema(description = "绑定单据表名") + @Dict(dicCode = "mes_xsl_approval_biz_doc") + private String bizTable; + + @Schema(description = "绑定单据中文名(冗余展示)") + private String bizTableName; + + @Schema(description = "单据标题字段名(发起选单据时展示)") + private String titleField; + + @Schema(description = "对应功能页面前端路由(发起审批悬浮按钮仅在该页显示)") + private String routePath; + + @Schema(description = "流程设计JSON(钉钉式节点树)") + private String flowConfig; + + @Schema(description = "状态:0草稿 1已发布 2已停用") + @Dict(dicCode = "mes_xsl_approval_flow_status") + private String status; + + @Schema(description = "排序") + private Double sortNo; + + @Schema(description = "备注") + private String remark; + + @Schema(description = "逻辑删除:0正常 1已删除") + @TableLogic + private Integer delFlag; + + @Schema(description = "租户ID") + private Integer tenantId; + + @Schema(description = "所属部门编码") + private String sysOrgCode; +} diff --git a/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/entity/MesXslApprovalInstance.java b/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/entity/MesXslApprovalInstance.java new file mode 100644 index 0000000..dac24c1 --- /dev/null +++ b/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/entity/MesXslApprovalInstance.java @@ -0,0 +1,95 @@ +package org.jeecg.modules.xslmes.approval.entity; + +import com.baomidou.mybatisplus.annotation.TableLogic; +import com.baomidou.mybatisplus.annotation.TableName; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.experimental.Accessors; +import org.jeecg.common.aspect.annotation.Dict; +import org.jeecg.common.system.base.entity.JeecgEntity; +import org.springframework.format.annotation.DateTimeFormat; + +import java.io.Serializable; +import java.util.Date; + +/** + * MES 审批实例(本期仅记录发起,不含办理) + * + * @author GHT + * @date 2026-05-29 for:【QH-MES审批流设计】发起审批运行时 + */ +@Data +@EqualsAndHashCode(callSuper = false) +@Accessors(chain = true) +@TableName("mes_xsl_approval_instance") +@Schema(description = "MES审批实例") +public class MesXslApprovalInstance extends JeecgEntity implements Serializable { + + private static final long serialVersionUID = 1L; + + @Schema(description = "审批流定义ID") + private String flowId; + + @Schema(description = "审批流名称") + private String flowName; + + @Schema(description = "业务单据表名") + private String bizTable; + + @Schema(description = "业务单据中文名") + private String bizTableName; + + @Schema(description = "业务单据记录ID") + private String bizDataId; + + @Schema(description = "业务单据展示标题") + private String bizTitle; + + @Schema(description = "当前节点ID") + private String currentNodeId; + + @Schema(description = "当前节点名称") + private String currentNodeName; + + @Schema(description = "当前处理人(username逗号分隔)") + private String currentHandlers; + + @Schema(description = "当前处理人展示文本") + private String currentHandlersText; + + //update-begin---author:GHT ---date:2026-05-29 for:【QH-MES审批流设计】审批办理/流转(会签/或签/依次)进度与历史----- + @Schema(description = "当前节点处理进度JSON(nodeId/mode/tasks)") + private String nodeProgress; + + @Schema(description = "审批历史JSON数组") + private String history; + //update-end---author:GHT ---date:2026-05-29 for:【QH-MES审批流设计】审批办理/流转(会签/或签/依次)进度与历史----- + + @Schema(description = "状态:0审批中 1已通过 2已驳回 3已撤销") + @Dict(dicCode = "mes_xsl_approval_instance_status") + private String status; + + @Schema(description = "发起人username") + private String applyUser; + + @Schema(description = "发起人姓名") + private String applyUserName; + + @Schema(description = "发起时间") + @DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss") + private Date applyTime; + + @Schema(description = "备注") + private String remark; + + @Schema(description = "逻辑删除:0正常 1已删除") + @TableLogic + private Integer delFlag; + + @Schema(description = "租户ID") + private Integer tenantId; + + @Schema(description = "所属部门编码") + private String sysOrgCode; +} diff --git a/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/mapper/MesXslApprovalFlowMapper.java b/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/mapper/MesXslApprovalFlowMapper.java new file mode 100644 index 0000000..64f2460 --- /dev/null +++ b/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/mapper/MesXslApprovalFlowMapper.java @@ -0,0 +1,15 @@ +package org.jeecg.modules.xslmes.approval.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import org.apache.ibatis.annotations.Mapper; +import org.jeecg.modules.xslmes.approval.entity.MesXslApprovalFlow; + +/** + * MES 审批流定义 Mapper + * + * @author GHT + * @date 2026-05-29 for:【QH-MES审批流设计】新增审批流可视化设计 + */ +@Mapper +public interface MesXslApprovalFlowMapper extends BaseMapper { +} diff --git a/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/mapper/MesXslApprovalInstanceMapper.java b/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/mapper/MesXslApprovalInstanceMapper.java new file mode 100644 index 0000000..0a37f8f --- /dev/null +++ b/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/mapper/MesXslApprovalInstanceMapper.java @@ -0,0 +1,15 @@ +package org.jeecg.modules.xslmes.approval.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import org.apache.ibatis.annotations.Mapper; +import org.jeecg.modules.xslmes.approval.entity.MesXslApprovalInstance; + +/** + * MES 审批实例 Mapper + * + * @author GHT + * @date 2026-05-29 for:【QH-MES审批流设计】发起审批运行时 + */ +@Mapper +public interface MesXslApprovalInstanceMapper extends BaseMapper { +} diff --git a/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/service/IMesXslApprovalFlowService.java b/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/service/IMesXslApprovalFlowService.java new file mode 100644 index 0000000..1a79682 --- /dev/null +++ b/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/service/IMesXslApprovalFlowService.java @@ -0,0 +1,13 @@ +package org.jeecg.modules.xslmes.approval.service; + +import com.baomidou.mybatisplus.extension.service.IService; +import org.jeecg.modules.xslmes.approval.entity.MesXslApprovalFlow; + +/** + * MES 审批流定义 Service + * + * @author GHT + * @date 2026-05-29 for:【QH-MES审批流设计】新增审批流可视化设计 + */ +public interface IMesXslApprovalFlowService extends IService { +} diff --git a/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/service/IMesXslApprovalHandleService.java b/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/service/IMesXslApprovalHandleService.java new file mode 100644 index 0000000..b66d60b --- /dev/null +++ b/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/service/IMesXslApprovalHandleService.java @@ -0,0 +1,44 @@ +package org.jeecg.modules.xslmes.approval.service; + +import org.jeecg.common.api.vo.Result; +import org.jeecg.common.system.vo.LoginUser; +import org.jeecg.modules.xslmes.approval.entity.MesXslApprovalInstance; + +import java.util.Map; + +/** + * MES 审批办理/流转引擎。 + * 负责:发起后进入首节点、审批通过/驳回的流转推进(支持会签/或签/依次)、查看单据全字段详情。 + * + * @author GHT + * @date 2026-05-29 for:【QH-MES审批流设计】审批办理/流转 + */ +public interface IMesXslApprovalHandleService { + + /** + * 发起后进入首个审批节点:解析处理人、初始化节点进度、发送审批卡片。 + * 实例需已先行 save(带 id)。 + */ + void enterFirstNode(MesXslApprovalInstance inst, LoginUser applyUser); + + /** + * 审批通过:标记当前处理人任务完成,按节点 multiMode 判断是否流转到下一节点。 + */ + Result approve(String instanceId, String comment, LoginUser user); + + /** + * 驳回:任一处理人驳回即终止流程,通知发起人。 + */ + Result reject(String instanceId, String reason, LoginUser user); + + /** + * 查看单据全部字段 + 审批进度/历史,供 IM 卡片"查看详情"弹窗使用。 + */ + Map detail(String instanceId, LoginUser user); + + /** + * 轻量状态查询:供 IM 卡片实时判断是否仍可办理(旧节点卡片置灰)。 + * 返回 status/statusText/currentNodeId/currentNodeName/canApprove。 + */ + Map statusInfo(String instanceId, LoginUser user); +} diff --git a/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/service/IMesXslApprovalInstanceService.java b/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/service/IMesXslApprovalInstanceService.java new file mode 100644 index 0000000..c423f04 --- /dev/null +++ b/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/service/IMesXslApprovalInstanceService.java @@ -0,0 +1,13 @@ +package org.jeecg.modules.xslmes.approval.service; + +import com.baomidou.mybatisplus.extension.service.IService; +import org.jeecg.modules.xslmes.approval.entity.MesXslApprovalInstance; + +/** + * MES 审批实例 Service + * + * @author GHT + * @date 2026-05-29 for:【QH-MES审批流设计】发起审批运行时 + */ +public interface IMesXslApprovalInstanceService extends IService { +} diff --git a/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/service/impl/MesXslApprovalFlowServiceImpl.java b/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/service/impl/MesXslApprovalFlowServiceImpl.java new file mode 100644 index 0000000..d4f0f91 --- /dev/null +++ b/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/service/impl/MesXslApprovalFlowServiceImpl.java @@ -0,0 +1,17 @@ +package org.jeecg.modules.xslmes.approval.service.impl; + +import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; +import org.jeecg.modules.xslmes.approval.entity.MesXslApprovalFlow; +import org.jeecg.modules.xslmes.approval.mapper.MesXslApprovalFlowMapper; +import org.jeecg.modules.xslmes.approval.service.IMesXslApprovalFlowService; +import org.springframework.stereotype.Service; + +/** + * MES 审批流定义 ServiceImpl + * + * @author GHT + * @date 2026-05-29 for:【QH-MES审批流设计】新增审批流可视化设计 + */ +@Service +public class MesXslApprovalFlowServiceImpl extends ServiceImpl implements IMesXslApprovalFlowService { +} diff --git a/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/service/impl/MesXslApprovalHandleServiceImpl.java b/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/service/impl/MesXslApprovalHandleServiceImpl.java new file mode 100644 index 0000000..413adbc --- /dev/null +++ b/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/service/impl/MesXslApprovalHandleServiceImpl.java @@ -0,0 +1,1023 @@ +package org.jeecg.modules.xslmes.approval.service.impl; + +import com.alibaba.fastjson2.JSON; +import com.alibaba.fastjson2.JSONArray; +import com.alibaba.fastjson2.JSONObject; +import lombok.extern.slf4j.Slf4j; +import org.jeecg.common.api.vo.Result; +import org.jeecg.common.system.vo.LoginUser; +import org.jeecg.common.util.oConvertUtils; +import org.jeecg.modules.im.service.ISysImChatService; +import org.jeecg.modules.system.entity.SysUser; +import org.jeecg.modules.system.service.ISysUserService; +import org.jeecg.modules.xslmes.approval.callback.ApprovalActionHttpExecutor; +import org.jeecg.modules.xslmes.approval.callback.ApprovalCallbackContext; +import org.jeecg.modules.xslmes.approval.callback.ApprovalCallbackDispatcher; +import org.jeecg.modules.xslmes.approval.entity.MesXslApprovalFlow; +import org.jeecg.modules.xslmes.approval.entity.MesXslApprovalInstance; +import org.jeecg.modules.xslmes.approval.service.IMesXslApprovalFlowService; +import org.jeecg.modules.xslmes.approval.service.IMesXslApprovalHandleService; +import org.jeecg.modules.xslmes.approval.service.IMesXslApprovalInstanceService; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.text.SimpleDateFormat; +import java.util.ArrayList; +import java.util.Date; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.regex.Pattern; + +/** + * MES 审批办理/流转引擎实现。 + * + * 节点进度(node_progress) JSON 结构: + * { "nodeId":"", "nodeName":"", "mode":"and|or|sequence", + * "tasks":[{"username":"","name":"","status":"pending|approved|rejected","seq":1,"comment":"","time":""}] } + * + * @author GHT + * @date 2026-05-29 for:【QH-MES审批流设计】审批办理/流转 + */ +@Slf4j +@Service +public class MesXslApprovalHandleServiceImpl implements IMesXslApprovalHandleService { + + /** 合法标识符(表名/字段名)白名单校验,防 SQL 注入 */ + private static final Pattern IDENTIFIER = Pattern.compile("^[A-Za-z0-9_]+$"); + + @Autowired + private IMesXslApprovalFlowService flowService; + + @Autowired + private IMesXslApprovalInstanceService instanceService; + + @Autowired + private JdbcTemplate jdbcTemplate; + + @Autowired + private ISysImChatService sysImChatService; + + @Autowired + private ISysUserService sysUserService; + + //update-begin---author:GHT ---date:2026-05-29 for:【QH-MES审批流设计】审批与业务单据联动回调----- + @Autowired + private ApprovalCallbackDispatcher callbackDispatcher; + + @Autowired + private ApprovalActionHttpExecutor actionHttpExecutor; + //update-end---author:GHT ---date:2026-05-29 for:【QH-MES审批流设计】审批与业务单据联动回调----- + + // ==================== 发起后进入首节点 ==================== + + @Override + @Transactional(rollbackFor = Exception.class) + public void enterFirstNode(MesXslApprovalInstance inst, LoginUser applyUser) { + MesXslApprovalFlow flow = flowService.getById(inst.getFlowId()); + if (flow == null || oConvertUtils.isEmpty(flow.getFlowConfig())) { + inst.setCurrentNodeName("无审批节点"); + inst.setCurrentHandlersText("流程未配置"); + instanceService.updateById(inst); + return; + } + JSONObject root; + try { + root = JSON.parseObject(flow.getFlowConfig()); + } catch (Exception e) { + log.error("解析流程设计失败 flowId={}", inst.getFlowId(), e); + return; + } + JSONObject firstApprover = findFirstApprover(root); + if (firstApprover == null) { + // 无审批节点 -> 自动通过 + inst.setStatus("1"); + inst.setCurrentNodeName("无审批节点"); + inst.setCurrentHandlersText("无审批节点,自动通过"); + instanceService.updateById(inst); + // 无审批节点直接最终通过 -> 回调业务 + callbackDispatcher.fireApproved(buildContext(inst, inst.getCurrentNodeId(), "无审批节点", applyUser, "无审批节点,自动通过")); + notifyApplicant(inst, applyUser == null ? null : applyUser.getUsername(), + "您发起的「" + safeTitle(inst) + "」无审批节点,已自动通过。"); + return; + } + enterNode(inst, flow, root, firstApprover); + } + + // ==================== 审批通过 ==================== + + @Override + @Transactional(rollbackFor = Exception.class) + public Result approve(String instanceId, String comment, LoginUser user) { + MesXslApprovalInstance inst = instanceService.getById(instanceId); + if (inst == null) { + return Result.error("审批实例不存在"); + } + if (!"0".equals(inst.getStatus())) { + return Result.error("该审批已结束,无需处理"); + } + JSONObject progress = parseProgress(inst); + if (progress == null) { + return Result.error("当前节点处理进度异常"); + } + JSONArray tasks = progress.getJSONArray("tasks"); + String mode = oConvertUtils.getString(progress.getString("mode"), "or"); + JSONObject myTask = findPendingTask(tasks, user.getUsername()); + if (myTask == null) { + return Result.error("您不是当前处理人,或已处理过"); + } + // 依次审批:必须轮到本人 + if ("sequence".equals(mode)) { + JSONObject active = firstPendingBySeq(tasks); + if (active != null && !user.getUsername().equals(active.getString("username"))) { + return Result.error("请等待前序处理人审批"); + } + } + myTask.put("status", "approved"); + myTask.put("comment", comment); + myTask.put("time", now()); + appendHistory(inst, progress.getString("nodeId"), progress.getString("nodeName"), user, "approve", comment); + + MesXslApprovalFlow flow = flowService.getById(inst.getFlowId()); + JSONObject root = flow == null ? null : safeParse(flow.getFlowConfig()); + + boolean complete; + if ("and".equals(mode)) { + complete = allApproved(tasks); + } else if ("sequence".equals(mode)) { + JSONObject next = firstPendingBySeq(tasks); + if (next != null) { + // 激活下一处理人 + inst.setCurrentHandlers(next.getString("username")); + inst.setCurrentHandlersText(next.getString("name")); + inst.setNodeProgress(progress.toJSONString()); + instanceService.updateById(inst); + if (flow != null) { + sendApprovalCard(inst, flow, progress.getString("nodeName"), singletonList(next.getString("username"))); + } + return Result.OK("已审批,已转下一处理人"); + } + complete = true; + } else { // or 或签:任一通过即完成 + complete = true; + } + + if (!complete) { + inst.setNodeProgress(progress.toJSONString()); + inst.setCurrentHandlersText(pendingNames(tasks) + " 待会签"); + instanceService.updateById(inst); + return Result.OK("已审批,等待其他会签人处理"); + } + + // 节点完成 -> 先回调业务(节点通过,中间态),再推进 + inst.setNodeProgress(progress.toJSONString()); + callbackDispatcher.fireNodeApproved(buildContext(inst, progress.getString("nodeId"), progress.getString("nodeName"), user, comment)); + if (root != null) { + actionHttpExecutor.run(findNodeById(root, progress.getString("nodeId")), "onNodeApprove", inst); + } + if (flow == null || root == null) { + inst.setStatus("1"); + inst.setCurrentHandlers(null); + inst.setCurrentHandlersText("审批通过"); + instanceService.updateById(inst); + // 无后续流程,直接最终通过 + callbackDispatcher.fireApproved(buildContext(inst, progress.getString("nodeId"), progress.getString("nodeName"), user, comment)); + return Result.OK("审批通过"); + } + advanceAfter(inst, flow, root, progress.getString("nodeId"), user); + return Result.OK("审批通过"); + } + + // ==================== 驳回 ==================== + + @Override + @Transactional(rollbackFor = Exception.class) + public Result reject(String instanceId, String reason, LoginUser user) { + MesXslApprovalInstance inst = instanceService.getById(instanceId); + if (inst == null) { + return Result.error("审批实例不存在"); + } + if (!"0".equals(inst.getStatus())) { + return Result.error("该审批已结束,无需处理"); + } + if (oConvertUtils.isEmpty(reason)) { + return Result.error("请填写驳回理由"); + } + JSONObject progress = parseProgress(inst); + if (progress == null) { + return Result.error("当前节点处理进度异常"); + } + JSONArray tasks = progress.getJSONArray("tasks"); + String mode = oConvertUtils.getString(progress.getString("mode"), "or"); + JSONObject myTask = findPendingTask(tasks, user.getUsername()); + if (myTask == null) { + return Result.error("您不是当前处理人,或已处理过"); + } + if ("sequence".equals(mode)) { + JSONObject active = firstPendingBySeq(tasks); + if (active != null && !user.getUsername().equals(active.getString("username"))) { + return Result.error("请等待前序处理人审批"); + } + } + myTask.put("status", "rejected"); + myTask.put("comment", reason); + myTask.put("time", now()); + appendHistory(inst, progress.getString("nodeId"), progress.getString("nodeName"), user, "reject", reason); + + inst.setStatus("2"); + inst.setCurrentHandlers(null); + inst.setCurrentHandlersText("已驳回"); + inst.setNodeProgress(progress.toJSONString()); + instanceService.updateById(inst); + + // 驳回 -> 回调业务(可回退业务状态) + callbackDispatcher.fireRejected(buildContext(inst, progress.getString("nodeId"), progress.getString("nodeName"), user, reason)); + MesXslApprovalFlow rejectFlow = flowService.getById(inst.getFlowId()); + JSONObject rejectRoot = rejectFlow == null ? null : safeParse(rejectFlow.getFlowConfig()); + if (rejectRoot != null) { + actionHttpExecutor.run(findNodeById(rejectRoot, progress.getString("nodeId")), "onReject", inst); + } + + String handlerName = oConvertUtils.getString(user.getRealname(), user.getUsername()); + notifyApplicant(inst, user.getUsername(), + "您发起的「" + safeTitle(inst) + "」被 " + handlerName + " 驳回。\n驳回理由:" + reason); + return Result.OK("已驳回"); + } + + // ==================== 查看详情 ==================== + + @Override + public Map detail(String instanceId, LoginUser user) { + Map data = new LinkedHashMap<>(); + MesXslApprovalInstance inst = instanceService.getById(instanceId); + if (inst == null) { + return data; + } + data.put("instanceId", inst.getId()); + data.put("flowName", inst.getFlowName()); + data.put("bizTableName", inst.getBizTableName()); + data.put("bizTitle", inst.getBizTitle()); + data.put("status", inst.getStatus()); + data.put("statusText", statusText(inst.getStatus())); + data.put("currentNodeName", inst.getCurrentNodeName()); + data.put("currentHandlersText", inst.getCurrentHandlersText()); + data.put("applyUserName", inst.getApplyUserName()); + data.put("applyTime", inst.getApplyTime()); + data.put("actionLabel", oConvertUtils.getString(inst.getCurrentNodeName(), "审批")); + // 单据全字段 + data.put("fields", readBizRecordFields(inst.getBizTable(), inst.getBizDataId())); + // 是否可办理:状态审批中 且 当前用户为活动待处理人 + data.put("canApprove", user != null && "0".equals(inst.getStatus()) && isActiveHandler(inst, user.getUsername())); + // 历史 + data.put("history", buildHistoryView(inst)); + return data; + } + + // ==================== 轻量状态查询(供卡片实时置灰) ==================== + + @Override + public Map statusInfo(String instanceId, LoginUser user) { + Map data = new LinkedHashMap<>(); + MesXslApprovalInstance inst = instanceService.getById(instanceId); + if (inst == null) { + data.put("exists", false); + return data; + } + data.put("exists", true); + data.put("status", inst.getStatus()); + data.put("statusText", statusText(inst.getStatus())); + data.put("currentNodeId", inst.getCurrentNodeId()); + data.put("currentNodeName", inst.getCurrentNodeName()); + data.put("currentHandlersText", inst.getCurrentHandlersText()); + // 当前用户在“当前节点”是否为活动待处理人 + data.put("canApprove", user != null && "0".equals(inst.getStatus()) && isActiveHandler(inst, user.getUsername())); + return data; + } + + // ==================== 进入节点(核心) ==================== + + private void enterNode(MesXslApprovalInstance inst, MesXslApprovalFlow flow, JSONObject root, JSONObject node) { + List> handlers = resolveHandlers(node, inst); + String mode = oConvertUtils.getString(node.getJSONObject("props") == null ? null : node.getJSONObject("props").getString("multiMode"), "or"); + String nodeName = oConvertUtils.getString(node.getString("name"), "审批"); + String nodeId = node.getString("id"); + + if (handlers.isEmpty()) { + JSONObject props = node.getJSONObject("props"); + String emptyStrategy = props == null ? "admin" : oConvertUtils.getString(props.getString("emptyStrategy"), "admin"); + if ("pass".equals(emptyStrategy)) { + appendHistory(inst, nodeId, nodeName, null, "approve", "审批人为空,自动通过"); + inst.setCurrentNodeId(nodeId); + inst.setCurrentNodeName(nodeName); + // 该节点自动通过(中间态) -> 回调业务 + callbackDispatcher.fireNodeApproved(buildContext(inst, nodeId, nodeName, null, "审批人为空,自动通过")); + actionHttpExecutor.run(node, "onNodeApprove", inst); + advanceAfter(inst, flow, root, nodeId, null); + return; + } + if ("stop".equals(emptyStrategy)) { + inst.setStatus("2"); + inst.setCurrentNodeId(nodeId); + inst.setCurrentNodeName(nodeName); + inst.setCurrentHandlers(null); + inst.setCurrentHandlersText("审批人为空,流程终止"); + instanceService.updateById(inst); + // 流程终止(等同驳回) -> 回调业务(可回退业务状态) + callbackDispatcher.fireRejected(buildContext(inst, nodeId, nodeName, null, "审批人为空,流程终止")); + actionHttpExecutor.run(node, "onReject", inst); + notifyApplicant(inst, null, "您发起的「" + safeTitle(inst) + "」在「" + nodeName + "」节点无处理人,流程已终止。"); + return; + } + // 默认转交管理员 + Map admin = adminHandler(); + if (admin != null) { + handlers.add(admin); + } + } + + JSONArray tasks = new JSONArray(); + int seq = 1; + for (Map h : handlers) { + JSONObject t = new JSONObject(); + t.put("username", h.get("username")); + t.put("name", h.get("name")); + t.put("status", "pending"); + t.put("seq", seq++); + t.put("comment", ""); + t.put("time", ""); + tasks.add(t); + } + JSONObject progress = new JSONObject(); + progress.put("nodeId", nodeId); + progress.put("nodeName", nodeName); + progress.put("mode", mode); + progress.put("tasks", tasks); + + // 活动处理人:依次审批仅首个,其余全部 + List activeUsers = new ArrayList<>(); + List activeNames = new ArrayList<>(); + for (int i = 0; i < tasks.size(); i++) { + JSONObject t = tasks.getJSONObject(i); + if ("sequence".equals(mode) && i > 0) { + break; + } + activeUsers.add(t.getString("username")); + activeNames.add(t.getString("name")); + } + + inst.setStatus("0"); + inst.setCurrentNodeId(nodeId); + inst.setCurrentNodeName(nodeName); + inst.setCurrentHandlers(String.join(",", activeUsers)); + inst.setCurrentHandlersText(String.join("、", activeNames)); + inst.setNodeProgress(progress.toJSONString()); + instanceService.updateById(inst); + + sendApprovalCard(inst, flow, nodeName, activeUsers); + } + + /** 当前节点完成后,查找下一审批节点并推进;无则置为通过并通知发起人。 */ + private void advanceAfter(MesXslApprovalInstance inst, MesXslApprovalFlow flow, JSONObject root, String currentNodeId, LoginUser actingUser) { + List chain = new ArrayList<>(); + flatten(root, chain); + int idx = -1; + for (int i = 0; i < chain.size(); i++) { + if (currentNodeId != null && currentNodeId.equals(chain.get(i).getString("id"))) { + idx = i; + break; + } + } + JSONObject nextApprover = null; + for (int i = idx + 1; i < chain.size(); i++) { + JSONObject n = chain.get(i); + String type = n.getString("type"); + if ("cc".equals(type)) { + sendCcNotify(inst, flow, n); + } else if ("approver".equals(type)) { + nextApprover = n; + break; + } + } + if (nextApprover != null) { + enterNode(inst, flow, root, nextApprover); + } else { + inst.setStatus("1"); + inst.setCurrentHandlers(null); + inst.setCurrentHandlersText("审批通过"); + instanceService.updateById(inst); + // 流程最终通过 -> 回调业务(终态) + callbackDispatcher.fireApproved(buildContext(inst, currentNodeId, inst.getCurrentNodeName(), actingUser, null)); + actionHttpExecutor.run(findNodeById(root, currentNodeId), "onApprove", inst); + String actor = actingUser == null ? null : actingUser.getUsername(); + notifyApplicant(inst, actor, "您发起的「" + safeTitle(inst) + "」审批已全部通过。"); + } + } + + // ==================== 处理人解析 ==================== + + private List> resolveHandlers(JSONObject node, MesXslApprovalInstance inst) { + List> result = new ArrayList<>(); + JSONObject props = node.getJSONObject("props"); + if (props == null) { + return result; + } + boolean isCc = "cc".equals(node.getString("type")); + String kind = isCc ? oConvertUtils.getString(props.getString("ccType"), "user") : oConvertUtils.getString(props.getString("approverType"), "user"); + switch (kind) { + case "self": + addHandler(result, inst.getApplyUser(), inst.getApplyUserName()); + break; + case "user": { + String userText = props.getString("userText"); + if (oConvertUtils.isNotEmpty(userText)) { + for (String uname : userText.split(",")) { + uname = uname == null ? "" : uname.trim(); + if (oConvertUtils.isEmpty(uname)) { + continue; + } + addHandler(result, uname, resolveRealname(uname)); + } + } + break; + } + case "role": { + JSONArray roles = props.getJSONArray("roleList"); + if (roles != null && !roles.isEmpty()) { + result.addAll(resolveUsersByRoles(roles)); + } + break; + } + case "field": { + String fieldName = props.getString("fieldName"); + String val = readBizFieldValue(inst.getBizTable(), inst.getBizDataId(), fieldName); + if (oConvertUtils.isNotEmpty(val)) { + for (String uname : val.split(",")) { + uname = uname == null ? "" : uname.trim(); + if (oConvertUtils.isEmpty(uname)) { + continue; + } + addHandler(result, uname, resolveRealname(uname)); + } + } + break; + } + case "leader": + default: + // 主管层级暂不解析具体人员 + break; + } + return result; + } + + private void addHandler(List> list, String username, String name) { + if (oConvertUtils.isEmpty(username)) { + return; + } + for (Map h : list) { + if (username.equals(h.get("username"))) { + return; + } + } + Map h = new LinkedHashMap<>(); + h.put("username", username); + h.put("name", oConvertUtils.getString(name, username)); + list.add(h); + } + + private String resolveRealname(String username) { + try { + SysUser u = sysUserService.getUserByName(username); + return u == null ? username : oConvertUtils.getString(u.getRealname(), username); + } catch (Exception e) { + return username; + } + } + + private List> resolveUsersByRoles(JSONArray roleIds) { + List> result = new ArrayList<>(); + if (roleIds == null || roleIds.isEmpty()) { + return result; + } + StringBuilder in = new StringBuilder(); + List args = new ArrayList<>(); + for (int i = 0; i < roleIds.size(); i++) { + if (i > 0) { + in.append(","); + } + in.append("?"); + args.add(roleIds.getString(i)); + } + String sql = "SELECT DISTINCT u.username AS username, u.realname AS realname FROM sys_user u " + + "JOIN sys_user_role ur ON ur.user_id = u.id " + + "WHERE ur.role_id IN (" + in + ") AND (u.del_flag = 0 OR u.del_flag IS NULL)"; + try { + List> rows = jdbcTemplate.queryForList(sql, args.toArray()); + for (Map row : rows) { + addHandler(result, String.valueOf(row.get("username")), + row.get("realname") == null ? null : String.valueOf(row.get("realname"))); + } + } catch (Exception e) { + log.warn("按角色解析处理人失败 roleIds={}", roleIds, e); + } + return result; + } + + private Map adminHandler() { + try { + SysUser admin = sysUserService.getUserByName("admin"); + if (admin != null) { + Map h = new LinkedHashMap<>(); + h.put("username", admin.getUsername()); + h.put("name", oConvertUtils.getString(admin.getRealname(), "管理员")); + return h; + } + } catch (Exception ignored) { + } + return null; + } + + // ==================== IM 卡片 ==================== + + /** 发送可办理的审批卡片给指定处理人 */ + private void sendApprovalCard(MesXslApprovalInstance inst, MesXslApprovalFlow flow, String actionLabel, List handlerUsernames) { + if (handlerUsernames == null || handlerUsernames.isEmpty()) { + return; + } + String routePath = resolveRoutePath(flow); + String content; + String msgType; + if (oConvertUtils.isNotEmpty(routePath)) { + content = buildCardJson(inst, actionLabel, true, routePath); + msgType = "biz_record"; + } else { + content = String.format("【审批提醒】%s 发起的「%s」需要您「%s」,请及时处理。", + oConvertUtils.getString(inst.getApplyUserName(), inst.getApplyUser()), inst.getFlowName(), actionLabel); + msgType = "text"; + } + SysUser applicant = getUserSafely(inst.getApplyUser()); + String fromId = applicant == null ? null : applicant.getId(); + for (String uname : handlerUsernames) { + sendOne(fromId, uname, inst.getTenantId(), content, msgType); + } + } + + /** 抄送通知(无办理按钮) */ + private void sendCcNotify(MesXslApprovalInstance inst, MesXslApprovalFlow flow, JSONObject ccNode) { + List> handlers = resolveHandlers(ccNode, inst); + if (handlers.isEmpty()) { + return; + } + String routePath = resolveRoutePath(flow); + String content; + String msgType; + if (oConvertUtils.isNotEmpty(routePath)) { + content = buildCardJson(inst, null, false, routePath); + msgType = "biz_record"; + } else { + content = String.format("【抄送通知】%s 发起的「%s」抄送给您。", + oConvertUtils.getString(inst.getApplyUserName(), inst.getApplyUser()), inst.getFlowName()); + msgType = "text"; + } + SysUser applicant = getUserSafely(inst.getApplyUser()); + String fromId = applicant == null ? null : applicant.getId(); + for (Map h : handlers) { + sendOne(fromId, String.valueOf(h.get("username")), inst.getTenantId(), content, msgType); + } + } + + /** 通知发起人审批结果(纯文本) */ + private void notifyApplicant(MesXslApprovalInstance inst, String fromUsername, String text) { + if (oConvertUtils.isEmpty(inst.getApplyUser())) { + return; + } + SysUser applicant = getUserSafely(inst.getApplyUser()); + if (applicant == null) { + return; + } + SysUser from = oConvertUtils.isNotEmpty(fromUsername) ? getUserSafely(fromUsername) : null; + String fromId = from == null ? applicant.getId() : from.getId(); + sendOne(fromId, inst.getApplyUser(), inst.getTenantId(), text, "text"); + } + + private void sendOne(String fromUserId, String toUsername, Integer tenantId, String content, String msgType) { + String uname = toUsername == null ? "" : toUsername.trim(); + if (oConvertUtils.isEmpty(uname)) { + return; + } + try { + SysUser to = sysUserService.getUserByName(uname); + if (to == null) { + return; + } + String realFrom = oConvertUtils.isNotEmpty(fromUserId) ? fromUserId : to.getId(); + sysImChatService.sendSystemSingleMessage(realFrom, to.getId(), tenantId, content, msgType); + } catch (Exception e) { + log.warn("发送审批IM消息失败 to={}", uname, e); + } + } + + /** 构建 biz_record 卡片 JSON(含 instanceId / actionLabel / canApprove,与前端 ImBizRecordPayload 对齐 v=2) */ + private String buildCardJson(MesXslApprovalInstance inst, String actionLabel, boolean canApprove, String routePath) { + JSONArray fields = new JSONArray(); + addField(fields, "审批流", inst.getFlowName()); + addField(fields, "单据", safeTitle(inst)); + addField(fields, "发起人", oConvertUtils.getString(inst.getApplyUserName(), inst.getApplyUser())); + addField(fields, "当前节点", oConvertUtils.getString(inst.getCurrentNodeName(), "审批")); + addField(fields, "状态", statusText(inst.getStatus())); + + JSONObject item = new JSONObject(); + item.put("recordId", inst.getBizDataId()); + item.put("fields", fields); + StringBuilder body = new StringBuilder(); + for (int i = 0; i < fields.size(); i++) { + JSONObject f = fields.getJSONObject(i); + if (i > 0) { + body.append("\n"); + } + body.append(f.getString("label")).append(": ").append(f.getString("value")); + } + item.put("body", body.toString()); + String sep = routePath.contains("?") ? "&" : "?"; + item.put("linkPath", routePath + sep + "imRecordId=" + inst.getBizDataId()); + // 审批扩展字段 + item.put("instanceId", inst.getId()); + item.put("canApprove", canApprove); + // 该卡片对应的节点ID(用于前端实时校验是否仍为当前节点,旧节点卡片自动置灰) + item.put("nodeId", inst.getCurrentNodeId()); + if (oConvertUtils.isNotEmpty(actionLabel)) { + item.put("actionLabel", actionLabel); + } + + JSONArray items = new JSONArray(); + items.add(item); + JSONObject payload = new JSONObject(); + payload.put("v", 2); + payload.put("pageTitle", oConvertUtils.getString(inst.getBizTableName(), inst.getFlowName())); + payload.put("pagePath", routePath); + payload.put("rowKey", "id"); + payload.put("items", items); + return payload.toJSONString(); + } + + private void addField(JSONArray fields, String label, String value) { + JSONObject f = new JSONObject(); + f.put("label", label); + f.put("value", oConvertUtils.getString(value, "")); + fields.add(f); + } + + // ==================== 业务表读取 ==================== + + /** 读取单据指定字段值 */ + private String readBizFieldValue(String table, String bizDataId, String field) { + if (oConvertUtils.isEmpty(table) || oConvertUtils.isEmpty(bizDataId) || oConvertUtils.isEmpty(field)) { + return null; + } + if (!IDENTIFIER.matcher(table).matches() || !IDENTIFIER.matcher(field).matches()) { + return null; + } + try { + List> rows = jdbcTemplate.queryForList( + "SELECT " + field + " FROM " + table + " WHERE id = ? LIMIT 1", bizDataId); + if (rows.isEmpty()) { + return null; + } + Object val = rows.get(0).get(field); + return val == null ? null : String.valueOf(val); + } catch (Exception e) { + log.warn("读取单据字段失败 table={}, field={}", table, field, e); + return null; + } + } + + /** 读取单据全部字段,以列注释为标签 */ + private JSONArray readBizRecordFields(String table, String bizDataId) { + JSONArray fields = new JSONArray(); + if (oConvertUtils.isEmpty(table) || oConvertUtils.isEmpty(bizDataId) || !IDENTIFIER.matcher(table).matches()) { + return fields; + } + try { + // 列名 -> 注释 + Map comments = new LinkedHashMap<>(); + List> cols = jdbcTemplate.queryForList( + "SELECT column_name AS name, column_comment AS comment FROM information_schema.columns " + + "WHERE table_schema = (SELECT DATABASE()) AND table_name = ? ORDER BY ordinal_position", table); + for (Map c : cols) { + comments.put(String.valueOf(c.get("name")), c.get("comment") == null ? "" : String.valueOf(c.get("comment"))); + } + List> rows = jdbcTemplate.queryForList("SELECT * FROM " + table + " WHERE id = ? LIMIT 1", bizDataId); + if (rows.isEmpty()) { + return fields; + } + Map row = rows.get(0); + for (Map.Entry e : row.entrySet()) { + String col = e.getKey(); + Object val = e.getValue(); + if (val == null || oConvertUtils.isEmpty(String.valueOf(val))) { + continue; + } + String label = comments.getOrDefault(col, col); + if (oConvertUtils.isEmpty(label)) { + label = col; + } + JSONObject f = new JSONObject(); + f.put("label", label); + f.put("value", String.valueOf(val)); + fields.add(f); + } + } catch (Exception e) { + log.warn("读取单据详情失败 table={}, id={}", table, bizDataId, e); + } + return fields; + } + + // ==================== 路由反查 ==================== + + private String resolveRoutePath(MesXslApprovalFlow flow) { + if (flow == null) { + return null; + } + if (oConvertUtils.isNotEmpty(flow.getRoutePath())) { + return flow.getRoutePath(); + } + return resolveRoutePathByTable(flow.getBizTable()); + } + + private String resolveRoutePathByTable(String table) { + if (oConvertUtils.isEmpty(table) || !IDENTIFIER.matcher(table).matches()) { + return null; + } + StringBuilder comp = new StringBuilder(); + for (String p : table.split("_")) { + if (oConvertUtils.isEmpty(p)) { + continue; + } + comp.append(Character.toUpperCase(p.charAt(0))).append(p.substring(1)); + } + comp.append("List"); + String sql = "SELECT url FROM sys_permission WHERE menu_type IN (0,1) " + + "AND (del_flag = 0 OR del_flag IS NULL) " + + "AND (component LIKE ? OR component LIKE ?) ORDER BY menu_type DESC LIMIT 1"; + try { + List urls = jdbcTemplate.queryForList(sql, String.class, "%/" + comp, "%" + comp); + return urls.isEmpty() ? null : urls.get(0); + } catch (Exception e) { + return null; + } + } + + // ==================== 流程树遍历 ==================== + + /** 沿流程树查找第一个审批节点(遇条件分支取首个分支后续) */ + private JSONObject findFirstApprover(JSONObject node) { + if (node == null) { + return null; + } + if ("approver".equals(node.getString("type"))) { + return node; + } + JSONArray branches = node.getJSONArray("conditionNodes"); + if (branches != null) { + for (int i = 0; i < branches.size(); i++) { + JSONObject found = findFirstApprover(branches.getJSONObject(i)); + if (found != null) { + return found; + } + } + } + return findFirstApprover(node.getJSONObject("childNode")); + } + + /** 按节点ID查找节点(含条件分支与子节点) */ + private JSONObject findNodeById(JSONObject node, String nodeId) { + if (node == null || oConvertUtils.isEmpty(nodeId)) { + return null; + } + if (nodeId.equals(node.getString("id"))) { + return node; + } + JSONArray branches = node.getJSONArray("conditionNodes"); + if (branches != null) { + for (int i = 0; i < branches.size(); i++) { + JSONObject found = findNodeById(branches.getJSONObject(i), nodeId); + if (found != null) { + return found; + } + } + } + return findNodeById(node.getJSONObject("childNode"), nodeId); + } + + /** 前序遍历收集审批人/抄送节点(条件分支按顺序展开) */ + private void flatten(JSONObject node, List out) { + if (node == null) { + return; + } + String type = node.getString("type"); + if ("approver".equals(type) || "cc".equals(type)) { + out.add(node); + } + JSONArray branches = node.getJSONArray("conditionNodes"); + if (branches != null) { + for (int i = 0; i < branches.size(); i++) { + flatten(branches.getJSONObject(i), out); + } + } + flatten(node.getJSONObject("childNode"), out); + } + + // ==================== 进度/历史辅助 ==================== + + private JSONObject parseProgress(MesXslApprovalInstance inst) { + if (oConvertUtils.isEmpty(inst.getNodeProgress())) { + return null; + } + try { + return JSON.parseObject(inst.getNodeProgress()); + } catch (Exception e) { + return null; + } + } + + private JSONObject safeParse(String json) { + if (oConvertUtils.isEmpty(json)) { + return null; + } + try { + return JSON.parseObject(json); + } catch (Exception e) { + return null; + } + } + + private JSONObject findPendingTask(JSONArray tasks, String username) { + if (tasks == null || oConvertUtils.isEmpty(username)) { + return null; + } + for (int i = 0; i < tasks.size(); i++) { + JSONObject t = tasks.getJSONObject(i); + if (username.equals(t.getString("username")) && "pending".equals(t.getString("status"))) { + return t; + } + } + return null; + } + + private JSONObject firstPendingBySeq(JSONArray tasks) { + JSONObject best = null; + int bestSeq = Integer.MAX_VALUE; + for (int i = 0; i < tasks.size(); i++) { + JSONObject t = tasks.getJSONObject(i); + if ("pending".equals(t.getString("status"))) { + int seq = t.getIntValue("seq"); + if (seq < bestSeq) { + bestSeq = seq; + best = t; + } + } + } + return best; + } + + private boolean allApproved(JSONArray tasks) { + for (int i = 0; i < tasks.size(); i++) { + if (!"approved".equals(tasks.getJSONObject(i).getString("status"))) { + return false; + } + } + return true; + } + + private String pendingNames(JSONArray tasks) { + List names = new ArrayList<>(); + for (int i = 0; i < tasks.size(); i++) { + JSONObject t = tasks.getJSONObject(i); + if ("pending".equals(t.getString("status"))) { + names.add(t.getString("name")); + } + } + return String.join("、", names); + } + + private boolean isActiveHandler(MesXslApprovalInstance inst, String username) { + if (oConvertUtils.isEmpty(inst.getCurrentHandlers()) || oConvertUtils.isEmpty(username)) { + return false; + } + for (String u : inst.getCurrentHandlers().split(",")) { + if (username.equals(u == null ? "" : u.trim())) { + return true; + } + } + return false; + } + + private void appendHistory(MesXslApprovalInstance inst, String nodeId, String nodeName, LoginUser user, String action, String comment) { + JSONArray history; + try { + history = oConvertUtils.isEmpty(inst.getHistory()) ? new JSONArray() : JSON.parseArray(inst.getHistory()); + } catch (Exception e) { + history = new JSONArray(); + } + JSONObject h = new JSONObject(); + h.put("nodeId", nodeId); + h.put("nodeName", nodeName); + h.put("username", user == null ? "system" : user.getUsername()); + h.put("name", user == null ? "系统" : oConvertUtils.getString(user.getRealname(), user.getUsername())); + h.put("action", action); + h.put("comment", oConvertUtils.getString(comment, "")); + h.put("time", now()); + history.add(h); + inst.setHistory(history.toJSONString()); + } + + private JSONArray buildHistoryView(MesXslApprovalInstance inst) { + JSONArray out = new JSONArray(); + if (oConvertUtils.isEmpty(inst.getHistory())) { + return out; + } + try { + JSONArray history = JSON.parseArray(inst.getHistory()); + for (int i = 0; i < history.size(); i++) { + JSONObject h = history.getJSONObject(i); + JSONObject v = new JSONObject(); + v.put("nodeName", h.getString("nodeName")); + v.put("name", h.getString("name")); + v.put("actionText", "approve".equals(h.getString("action")) ? "通过" : "驳回"); + v.put("comment", h.getString("comment")); + v.put("time", h.getString("time")); + out.add(v); + } + } catch (Exception ignored) { + } + return out; + } + + // ==================== 业务回调上下文 ==================== + + /** 构建审批回调上下文(action 由分发器按触发方法设置) */ + private ApprovalCallbackContext buildContext(MesXslApprovalInstance inst, String nodeId, String nodeName, LoginUser user, String comment) { + ApprovalCallbackContext ctx = new ApprovalCallbackContext(); + ctx.setInstanceId(inst.getId()); + ctx.setFlowId(inst.getFlowId()); + ctx.setFlowName(inst.getFlowName()); + ctx.setBizTable(inst.getBizTable()); + ctx.setBizTableName(inst.getBizTableName()); + ctx.setBizDataId(inst.getBizDataId()); + ctx.setBizTitle(inst.getBizTitle()); + ctx.setNodeId(nodeId); + ctx.setNodeName(nodeName); + ctx.setApplyUser(inst.getApplyUser()); + ctx.setComment(comment); + ctx.setInstance(inst); + if (user != null) { + ctx.setOperatorUsername(user.getUsername()); + ctx.setOperatorName(oConvertUtils.getString(user.getRealname(), user.getUsername())); + } else { + ctx.setOperatorUsername("system"); + ctx.setOperatorName("系统"); + } + return ctx; + } + + // ==================== 杂项 ==================== + + private SysUser getUserSafely(String username) { + if (oConvertUtils.isEmpty(username)) { + return null; + } + try { + return sysUserService.getUserByName(username); + } catch (Exception e) { + return null; + } + } + + private List singletonList(String s) { + List l = new ArrayList<>(); + l.add(s); + return l; + } + + private String safeTitle(MesXslApprovalInstance inst) { + return oConvertUtils.getString(inst.getBizTitle(), inst.getBizDataId()); + } + + private String statusText(String status) { + if ("1".equals(status)) { + return "已通过"; + } + if ("2".equals(status)) { + return "已驳回"; + } + if ("3".equals(status)) { + return "已撤销"; + } + return "审批中"; + } + + private String now() { + return new SimpleDateFormat("yyyy-MM-dd HH:mm:ss").format(new Date()); + } +} diff --git a/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/service/impl/MesXslApprovalInstanceServiceImpl.java b/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/service/impl/MesXslApprovalInstanceServiceImpl.java new file mode 100644 index 0000000..daa83ce --- /dev/null +++ b/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/approval/service/impl/MesXslApprovalInstanceServiceImpl.java @@ -0,0 +1,17 @@ +package org.jeecg.modules.xslmes.approval.service.impl; + +import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; +import org.jeecg.modules.xslmes.approval.entity.MesXslApprovalInstance; +import org.jeecg.modules.xslmes.approval.mapper.MesXslApprovalInstanceMapper; +import org.jeecg.modules.xslmes.approval.service.IMesXslApprovalInstanceService; +import org.springframework.stereotype.Service; + +/** + * MES 审批实例 ServiceImpl + * + * @author GHT + * @date 2026-05-29 for:【QH-MES审批流设计】发起审批运行时 + */ +@Service +public class MesXslApprovalInstanceServiceImpl extends ServiceImpl implements IMesXslApprovalInstanceService { +} diff --git a/jeecg-boot/jeecg-module-system/jeecg-system-biz/src/main/java/org/jeecg/modules/im/service/ISysImChatService.java b/jeecg-boot/jeecg-module-system/jeecg-system-biz/src/main/java/org/jeecg/modules/im/service/ISysImChatService.java index 0ceebee..621d174 100644 --- a/jeecg-boot/jeecg-module-system/jeecg-system-biz/src/main/java/org/jeecg/modules/im/service/ISysImChatService.java +++ b/jeecg-boot/jeecg-module-system/jeecg-system-biz/src/main/java/org/jeecg/modules/im/service/ISysImChatService.java @@ -48,6 +48,21 @@ public interface ISysImChatService { SysImMessageVO sendMessage(String userId, Integer tenantId, SysImSendMessageDTO dto); + //update-begin---author:GHT ---date:2026-05-29 for:【QH-MES审批流设计】系统单聊消息(绕过同部门校验,供审批等系统通知场景)----- + /** + * 系统消息:以 fromUser 身份给 toUser 发送单聊消息。 + * 与 sendMessage 区别:自动获取/创建单聊会话,且不做"同部门/同租户聊天"校验,专供审批通知等系统场景。 + * + * @param fromUserId 发送人用户ID(一般为业务发起人) + * @param toUserId 接收人用户ID + * @param tenantId 租户ID + * @param content 消息内容 + * @param msgType 消息类型 text/biz_record 等,空则按 text + * @return 消息VO,发送失败返回 null + */ + SysImMessageVO sendSystemSingleMessage(String fromUserId, String toUserId, Integer tenantId, String content, String msgType); + //update-end---author:GHT ---date:2026-05-29 for:【QH-MES审批流设计】系统单聊消息(绕过同部门校验,供审批等系统通知场景)----- + void markRead(String userId, String conversationId); diff --git a/jeecg-boot/jeecg-module-system/jeecg-system-biz/src/main/java/org/jeecg/modules/im/service/impl/SysImChatServiceImpl.java b/jeecg-boot/jeecg-module-system/jeecg-system-biz/src/main/java/org/jeecg/modules/im/service/impl/SysImChatServiceImpl.java index bec7fc7..d715f1f 100644 --- a/jeecg-boot/jeecg-module-system/jeecg-system-biz/src/main/java/org/jeecg/modules/im/service/impl/SysImChatServiceImpl.java +++ b/jeecg-boot/jeecg-module-system/jeecg-system-biz/src/main/java/org/jeecg/modules/im/service/impl/SysImChatServiceImpl.java @@ -473,6 +473,57 @@ public class SysImChatServiceImpl implements ISysImChatService { } //update-end---author:cursor ---date:20260528 for:【IM聊天-OA】发送消息----------- + //update-begin---author:GHT ---date:2026-05-29 for:【QH-MES审批流设计】系统单聊消息(绕过同部门校验)----- + @Override + @Transactional(rollbackFor = Exception.class) + public SysImMessageVO sendSystemSingleMessage(String fromUserId, String toUserId, Integer tenantId, String content, String msgType) { + if (oConvertUtils.isEmpty(fromUserId) || oConvertUtils.isEmpty(toUserId) || oConvertUtils.isEmpty(content)) { + return null; + } + // 不给自己发送 + if (fromUserId.equals(toUserId)) { + return null; + } + Integer tenant = tenantId == null ? 0 : tenantId; + Date now = new Date(); + // 获取或创建单聊会话(系统通知场景,不做同部门校验) + String pairKey = buildPairKey(fromUserId, toUserId); + SysImConversation conversation = conversationMapper.selectOne(new LambdaQueryWrapper() + .eq(SysImConversation::getTenantId, tenant) + .eq(SysImConversation::getUserPairKey, pairKey)); + if (conversation == null) { + conversation = new SysImConversation(); + conversation.setConvType(CONV_TYPE_SINGLE); + conversation.setUserPairKey(pairKey); + conversation.setTenantId(tenant); + conversation.setCreateBy(fromUserId); + conversation.setCreateTime(now); + conversation.setUpdateTime(now); + conversationMapper.insert(conversation); + createMember(conversation.getId(), fromUserId, now); + createMember(conversation.getId(), toUserId, now); + } + // 写入消息 + SysImMessage message = new SysImMessage(); + message.setConversationId(conversation.getId()); + message.setSenderId(fromUserId); + message.setContent(content.trim()); + message.setMsgType(oConvertUtils.isEmpty(msgType) ? MSG_TYPE_TEXT : msgType); + message.setTenantId(tenant); + message.setCreateTime(now); + messageMapper.insert(message); + // 更新会话摘要与未读、推送 + conversation.setLastContent(truncate(resolveLastContent(message.getMsgType(), message.getContent()), 200)); + conversation.setLastTime(now); + conversation.setUpdateTime(now); + conversationMapper.updateById(conversation); + memberMapper.incrementUnreadExceptSender(conversation.getId(), fromUserId); + SysImMessageVO messageVo = toMessageVo(message, fromUserId); + pushChatMessage(conversation.getId(), fromUserId, messageVo, conversation.getConvType()); + return messageVo; + } + //update-end---author:GHT ---date:2026-05-29 for:【QH-MES审批流设计】系统单聊消息(绕过同部门校验)----- + //update-begin---author:cursor ---date:20260528 for:【IM聊天-OA】标记已读----------- @Override @Transactional(rollbackFor = Exception.class) diff --git a/jeecg-boot/jeecg-module-system/jeecg-system-start/src/main/resources/flyway/sql/mysql/V3.9.2_111__mes_xsl_approval_flow.sql b/jeecg-boot/jeecg-module-system/jeecg-system-start/src/main/resources/flyway/sql/mysql/V3.9.2_111__mes_xsl_approval_flow.sql new file mode 100644 index 0000000..6c200c4 --- /dev/null +++ b/jeecg-boot/jeecg-module-system/jeecg-system-start/src/main/resources/flyway/sql/mysql/V3.9.2_111__mes_xsl_approval_flow.sql @@ -0,0 +1,97 @@ +-- 【QH-MES审批流设计】审批流定义表 + 可审批单据字典 + 我的租户菜单与授权 +SET NAMES utf8mb4; + +-- 1) 审批流定义表 +CREATE TABLE IF NOT EXISTS `mes_xsl_approval_flow` ( + `id` varchar(32) NOT NULL COMMENT '主键', + `flow_name` varchar(100) NOT NULL COMMENT '审批流名称', + `biz_table` varchar(100) NOT NULL COMMENT '绑定单据表名', + `biz_table_name` varchar(200) DEFAULT NULL COMMENT '绑定单据中文名(冗余展示)', + `flow_config` longtext COMMENT '流程设计JSON(钉钉式节点树)', + `status` varchar(1) DEFAULT '0' COMMENT '状态 0草稿 1已发布 2已停用', + `sort_no` double DEFAULT '0' COMMENT '排序', + `remark` varchar(500) DEFAULT NULL COMMENT '备注', + `del_flag` int DEFAULT '0' COMMENT '逻辑删除 0正常 1已删除', + `tenant_id` int DEFAULT NULL COMMENT '租户ID', + `sys_org_code` varchar(64) DEFAULT NULL COMMENT '所属部门编码', + `create_by` varchar(50) DEFAULT NULL COMMENT '创建人', + `create_time` datetime DEFAULT NULL COMMENT '创建时间', + `update_by` varchar(50) DEFAULT NULL COMMENT '更新人', + `update_time` datetime DEFAULT NULL COMMENT '更新时间', + PRIMARY KEY (`id`), + KEY `idx_appr_flow_tenant_biz` (`tenant_id`, `biz_table`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='MES审批流定义表'; + +-- 2) 可审批单据字典(单据来源:MES现有业务表) item_value=表名 item_text=单据中文名 +INSERT IGNORE INTO `sys_dict` (`id`, `dict_name`, `dict_code`, `description`, `del_flag`, `create_by`, `create_time`, `type`, `tenant_id`) +VALUES ('1995000000000000310', '可审批单据', 'mes_xsl_approval_biz_doc', 'MES审批流可绑定的业务单据', 0, 'admin', NOW(), 0, 0); + +INSERT IGNORE INTO `sys_dict_item` (`id`, `dict_id`, `item_text`, `item_value`, `description`, `sort_order`, `status`, `create_by`, `create_time`) +VALUES +('1995000000000000311', '1995000000000000310', '配合示方', 'mes_xsl_formula_spec', '配合示方主表', 1, 1, 'admin', NOW()), +('1995000000000000312', '1995000000000000310', '混炼示方', 'mes_xsl_mixing_spec', '混炼示方主表', 2, 1, 'admin', NOW()), +('1995000000000000313', '1995000000000000310', '密炼PS编制', 'mes_xsl_mixer_ps_compile', '密炼PS编制', 3, 1, 'admin', NOW()), +('1995000000000000314', '1995000000000000310', '胶料快检标准', 'mes_xsl_rubber_quick_test_std', '胶料快检实验标准', 4, 1, 'admin', NOW()), +('1995000000000000315', '1995000000000000310', '原料入场记录', 'mes_xsl_raw_material_entry', '原料入场记录', 5, 1, 'admin', NOW()); + +-- 2.1) 审批流状态字典 +INSERT IGNORE INTO `sys_dict` (`id`, `dict_name`, `dict_code`, `description`, `del_flag`, `create_by`, `create_time`, `type`, `tenant_id`) +VALUES ('1995000000000000320', '审批流状态', 'mes_xsl_approval_flow_status', 'MES审批流定义状态', 0, 'admin', NOW(), 0, 0); + +INSERT IGNORE INTO `sys_dict_item` (`id`, `dict_id`, `item_text`, `item_value`, `description`, `sort_order`, `status`, `create_by`, `create_time`) +VALUES +('1995000000000000321', '1995000000000000320', '草稿', '0', '草稿', 1, 1, 'admin', NOW()), +('1995000000000000322', '1995000000000000320', '已发布', '1', '已发布', 2, 1, 'admin', NOW()), +('1995000000000000323', '1995000000000000320', '已停用', '2', '已停用', 3, 1, 'admin', NOW()); + +-- 3) 我的租户 -> 审批流设计 菜单(parent_id=我的租户 1674708136602542082) +INSERT IGNORE 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 ( + '1995000000000000301', '1674708136602542082', '审批流设计', '/approval/ApprovalFlowList', + 'approval/flow/ApprovalFlowList', 1, 'ApprovalFlowList', NULL, + 1, NULL, '0', 4.00, 0, 'ant-design:partition-outlined', 0, 1, + 0, 0, '租户审批流可视化设计', 'admin', NOW(), 'admin', NOW(), + 0, 0, '1', 0 +); + +-- 按钮权限 +INSERT IGNORE INTO `sys_permission` (`id`, `parent_id`, `name`, `menu_type`, `perms`, `perms_type`, `sort_no`, `is_route`, `is_leaf`, `hidden`, `status`, `del_flag`, `create_by`, `create_time`) +VALUES ('1995000000000000302', '1995000000000000301', '查询', 2, 'approval:flow:list', '1', 1.00, 0, 1, 0, '1', 0, 'admin', NOW()); + +INSERT IGNORE INTO `sys_permission` (`id`, `parent_id`, `name`, `menu_type`, `perms`, `perms_type`, `sort_no`, `is_route`, `is_leaf`, `hidden`, `status`, `del_flag`, `create_by`, `create_time`) +VALUES ('1995000000000000303', '1995000000000000301', '新增', 2, 'approval:flow:add', '1', 2.00, 0, 1, 0, '1', 0, 'admin', NOW()); + +INSERT IGNORE INTO `sys_permission` (`id`, `parent_id`, `name`, `menu_type`, `perms`, `perms_type`, `sort_no`, `is_route`, `is_leaf`, `hidden`, `status`, `del_flag`, `create_by`, `create_time`) +VALUES ('1995000000000000304', '1995000000000000301', '编辑', 2, 'approval:flow:edit', '1', 3.00, 0, 1, 0, '1', 0, 'admin', NOW()); + +INSERT IGNORE INTO `sys_permission` (`id`, `parent_id`, `name`, `menu_type`, `perms`, `perms_type`, `sort_no`, `is_route`, `is_leaf`, `hidden`, `status`, `del_flag`, `create_by`, `create_time`) +VALUES ('1995000000000000305', '1995000000000000301', '删除', 2, 'approval:flow:delete', '1', 4.00, 0, 1, 0, '1', 0, 'admin', NOW()); + +INSERT IGNORE INTO `sys_permission` (`id`, `parent_id`, `name`, `menu_type`, `perms`, `perms_type`, `sort_no`, `is_route`, `is_leaf`, `hidden`, `status`, `del_flag`, `create_by`, `create_time`) +VALUES ('1995000000000000306', '1995000000000000301', '设计/发布', 2, 'approval:flow:design', '1', 5.00, 0, 1, 0, '1', 0, 'admin', NOW()); + +-- 4) 授权给超级管理员 admin +INSERT INTO `sys_role_permission` (`id`, `role_id`, `permission_id`, `data_rule_ids`, `operate_date`, `operate_ip`) +SELECT REPLACE(UUID(), '-', ''), r.id, p.id, NULL, NOW(), '127.0.0.1' +FROM `sys_role` r +CROSS JOIN `sys_permission` p +WHERE r.`role_code` = 'admin' + AND p.`id` IN ('1995000000000000301','1995000000000000302','1995000000000000303','1995000000000000304','1995000000000000305','1995000000000000306') + AND NOT EXISTS ( + SELECT 1 FROM `sys_role_permission` rp WHERE rp.`role_id` = r.`id` AND rp.`permission_id` = p.`id` + ); + +-- 5) 授权给租户管理员 zuhuadmin(挂在"我的租户"下必须授权,否则租户管理员看不到) +INSERT INTO `sys_role_permission` (`id`, `role_id`, `permission_id`, `data_rule_ids`, `operate_date`, `operate_ip`) +SELECT REPLACE(UUID(), '-', ''), r.id, p.id, NULL, NOW(), '127.0.0.1' +FROM `sys_role` r +CROSS JOIN `sys_permission` p +WHERE r.`role_code` = 'zuhuadmin' + AND p.`id` IN ('1995000000000000301','1995000000000000302','1995000000000000303','1995000000000000304','1995000000000000305','1995000000000000306') + AND NOT EXISTS ( + SELECT 1 FROM `sys_role_permission` rp WHERE rp.`role_id` = r.`id` AND rp.`permission_id` = p.`id` + ); diff --git a/jeecg-boot/jeecg-module-system/jeecg-system-start/src/main/resources/flyway/sql/mysql/V3.9.2_112__mes_xsl_approval_instance.sql b/jeecg-boot/jeecg-module-system/jeecg-system-start/src/main/resources/flyway/sql/mysql/V3.9.2_112__mes_xsl_approval_instance.sql new file mode 100644 index 0000000..70bbc0b --- /dev/null +++ b/jeecg-boot/jeecg-module-system/jeecg-system-start/src/main/resources/flyway/sql/mysql/V3.9.2_112__mes_xsl_approval_instance.sql @@ -0,0 +1,47 @@ +-- 【QH-MES审批流设计】审批实例表 + 审批流定义增加单据标题字段 +SET NAMES utf8mb4; + +-- 1) 审批流定义表增加"单据标题字段名",发起时用于展示具体单据 +ALTER TABLE `mes_xsl_approval_flow` + ADD COLUMN `title_field` varchar(100) DEFAULT NULL COMMENT '单据标题字段名(发起选单据时展示)' AFTER `biz_table_name`; + +-- 2) 审批实例表(本期仅发起,记录实例与当前处理人) +CREATE TABLE IF NOT EXISTS `mes_xsl_approval_instance` ( + `id` varchar(32) NOT NULL COMMENT '主键', + `flow_id` varchar(32) NOT NULL COMMENT '审批流定义ID', + `flow_name` varchar(100) DEFAULT NULL COMMENT '审批流名称', + `biz_table` varchar(100) DEFAULT NULL COMMENT '业务单据表名', + `biz_table_name` varchar(200) DEFAULT NULL COMMENT '业务单据中文名', + `biz_data_id` varchar(64) DEFAULT NULL COMMENT '业务单据记录ID', + `biz_title` varchar(300) DEFAULT NULL COMMENT '业务单据展示标题', + `current_node_id` varchar(64) DEFAULT NULL COMMENT '当前节点ID', + `current_node_name` varchar(100) DEFAULT NULL COMMENT '当前节点名称', + `current_handlers` varchar(1000) DEFAULT NULL COMMENT '当前处理人(username逗号分隔)', + `current_handlers_text` varchar(1000) DEFAULT NULL COMMENT '当前处理人展示文本', + `status` varchar(2) DEFAULT '0' COMMENT '状态 0审批中 1已通过 2已驳回 3已撤销', + `apply_user` varchar(50) DEFAULT NULL COMMENT '发起人username', + `apply_user_name` varchar(100) DEFAULT NULL COMMENT '发起人姓名', + `apply_time` datetime DEFAULT NULL COMMENT '发起时间', + `remark` varchar(500) DEFAULT NULL COMMENT '备注', + `del_flag` int DEFAULT '0' COMMENT '逻辑删除 0正常 1已删除', + `tenant_id` int DEFAULT NULL COMMENT '租户ID', + `sys_org_code` varchar(64) DEFAULT NULL COMMENT '所属部门编码', + `create_by` varchar(50) DEFAULT NULL COMMENT '创建人', + `create_time` datetime DEFAULT NULL COMMENT '创建时间', + `update_by` varchar(50) DEFAULT NULL COMMENT '更新人', + `update_time` datetime DEFAULT NULL COMMENT '更新时间', + PRIMARY KEY (`id`), + KEY `idx_appr_inst_tenant` (`tenant_id`, `flow_id`), + KEY `idx_appr_inst_apply` (`apply_user`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='MES审批实例表'; + +-- 3) 审批实例状态字典 +INSERT IGNORE INTO `sys_dict` (`id`, `dict_name`, `dict_code`, `description`, `del_flag`, `create_by`, `create_time`, `type`, `tenant_id`) +VALUES ('1995000000000000330', '审批实例状态', 'mes_xsl_approval_instance_status', 'MES审批实例状态', 0, 'admin', NOW(), 0, 0); + +INSERT IGNORE INTO `sys_dict_item` (`id`, `dict_id`, `item_text`, `item_value`, `description`, `sort_order`, `status`, `create_by`, `create_time`) +VALUES +('1995000000000000331', '1995000000000000330', '审批中', '0', '审批中', 1, 1, 'admin', NOW()), +('1995000000000000332', '1995000000000000330', '已通过', '1', '已通过', 2, 1, 'admin', NOW()), +('1995000000000000333', '1995000000000000330', '已驳回', '2', '已驳回', 3, 1, 'admin', NOW()), +('1995000000000000334', '1995000000000000330', '已撤销', '3', '已撤销', 4, 1, 'admin', NOW()); diff --git a/jeecg-boot/jeecg-module-system/jeecg-system-start/src/main/resources/flyway/sql/mysql/V3.9.2_113__mes_xsl_approval_flow_route.sql b/jeecg-boot/jeecg-module-system/jeecg-system-start/src/main/resources/flyway/sql/mysql/V3.9.2_113__mes_xsl_approval_flow_route.sql new file mode 100644 index 0000000..51b9f8b --- /dev/null +++ b/jeecg-boot/jeecg-module-system/jeecg-system-start/src/main/resources/flyway/sql/mysql/V3.9.2_113__mes_xsl_approval_flow_route.sql @@ -0,0 +1,5 @@ +-- 【QH-MES审批流设计】审批流定义增加"功能页面路由",用于控制发起审批悬浮按钮仅在对应功能页显示 +SET NAMES utf8mb4; + +ALTER TABLE `mes_xsl_approval_flow` + ADD COLUMN `route_path` varchar(255) DEFAULT NULL COMMENT '对应功能页面前端路由(发起审批悬浮按钮仅在该页显示)' AFTER `title_field`; diff --git a/jeecg-boot/jeecg-module-system/jeecg-system-start/src/main/resources/flyway/sql/mysql/V3.9.2_114__mes_xsl_approval_handle.sql b/jeecg-boot/jeecg-module-system/jeecg-system-start/src/main/resources/flyway/sql/mysql/V3.9.2_114__mes_xsl_approval_handle.sql new file mode 100644 index 0000000..f3a43b7 --- /dev/null +++ b/jeecg-boot/jeecg-module-system/jeecg-system-start/src/main/resources/flyway/sql/mysql/V3.9.2_114__mes_xsl_approval_handle.sql @@ -0,0 +1,6 @@ +-- 【QH-MES审批流设计】审批办理/流转:实例表增加当前节点处理进度与历史(支持会签/或签/依次) +SET NAMES utf8mb4; + +ALTER TABLE `mes_xsl_approval_instance` + ADD COLUMN `node_progress` longtext COMMENT '当前节点处理进度JSON(nodeId/mode/tasks)' AFTER `current_handlers_text`, + ADD COLUMN `history` longtext COMMENT '审批历史JSON数组' AFTER `node_progress`; diff --git a/jeecgboot-vue3/src/components/ApprovalDesign/index.vue b/jeecgboot-vue3/src/components/ApprovalDesign/index.vue new file mode 100644 index 0000000..10196a4 --- /dev/null +++ b/jeecgboot-vue3/src/components/ApprovalDesign/index.vue @@ -0,0 +1,113 @@ + + + + + + diff --git a/jeecgboot-vue3/src/components/ApprovalLaunch/index.vue b/jeecgboot-vue3/src/components/ApprovalLaunch/index.vue new file mode 100644 index 0000000..a2c8e6d --- /dev/null +++ b/jeecgboot-vue3/src/components/ApprovalLaunch/index.vue @@ -0,0 +1,283 @@ + + + + + + diff --git a/jeecgboot-vue3/src/components/ApprovalLaunch/useApprovalSelection.ts b/jeecgboot-vue3/src/components/ApprovalLaunch/useApprovalSelection.ts new file mode 100644 index 0000000..86daa41 --- /dev/null +++ b/jeecgboot-vue3/src/components/ApprovalLaunch/useApprovalSelection.ts @@ -0,0 +1,33 @@ +import { ref } from 'vue'; + +/** + * 审批发起-列表选中上下文(全局单例) + * 由 useListPage 自动把当前列表页的选中行同步进来, + * 全局「发起审批」悬浮按钮发起时直接读取,实现"列表多选 -> 发起弹窗"联动。 + * + * @author GHT + * @date 2026-05-29 for:【QH-MES审批流设计】发起审批支持列表多选联动 + */ +// 当前选中的行记录 +const rows = ref([]); +// 选中来源页面路由,用于校验与当前页是否一致,避免跨页串数据 +const sourcePath = ref(''); + +export function useApprovalSelection() { + function setSelection(list: any[], path: string) { + rows.value = Array.isArray(list) ? [...list] : []; + sourcePath.value = path || ''; + } + + function clear() { + rows.value = []; + sourcePath.value = ''; + } + + /** 获取与指定路由匹配的选中行(不匹配返回空) */ + function getRowsByPath(path: string): any[] { + return sourcePath.value === path ? rows.value : []; + } + + return { rows, sourcePath, setSelection, clear, getRowsByPath }; +} diff --git a/jeecgboot-vue3/src/hooks/system/useListPage.ts b/jeecgboot-vue3/src/hooks/system/useListPage.ts index 1cfeebb..fcdc382 100644 --- a/jeecgboot-vue3/src/hooks/system/useListPage.ts +++ b/jeecgboot-vue3/src/hooks/system/useListPage.ts @@ -11,6 +11,9 @@ import { useDesign } from '/@/hooks/web/useDesign'; import { filterObj } from '/@/utils/common/compUtils'; import { isFunction } from '@/utils/is'; import { registerImPageListProvider } from '/@/views/system/im/imPageListRegistry'; +//update-begin---author:GHT ---date:2026-05-29 for:【QH-MES审批流设计】列表选中行同步到审批发起上下文----- +import { useApprovalSelection } from '/@/components/ApprovalLaunch/useApprovalSelection'; +//update-end---author:GHT ---date:2026-05-29 for:【QH-MES审批流设计】列表选中行同步到审批发起上下文----- import { buildImPageListSnapshot } from '/@/views/system/im/imPageListUtil'; import { IM_RECORD_QUERY_KEY } from '/@/views/system/im/imBizRecordMessage'; import { @@ -71,7 +74,7 @@ export function useListPage(options: ListPageOptions) { const tableContext = useListTable(options.tableProps); const route = useRoute(); - const [, tableMethods, { selectedRowKeys }] = tableContext; + const [, tableMethods, { selectedRowKeys, selectedRows }] = tableContext; const { getForm, reload, setLoading, getColumns } = tableMethods; const imHighlightRecordId = ref(''); let clearHighlightTimer: ReturnType | null = null; @@ -84,6 +87,16 @@ export function useListPage(options: ListPageOptions) { } }); + //update-begin---author:GHT ---date:2026-05-29 for:【QH-MES审批流设计】列表选中行同步到审批发起上下文,供全局"发起审批"悬浮按钮读取----- + const approvalSelection = useApprovalSelection(); + watch( + selectedRows, + (rows) => approvalSelection.setSelection((rows as any[]) || [], route.path), + { deep: true }, + ); + onUnmounted(() => approvalSelection.clear()); + //update-end---author:GHT ---date:2026-05-29 for:【QH-MES审批流设计】列表选中行同步到审批发起上下文,供全局"发起审批"悬浮按钮读取----- + //update-begin---author:xsl ---date:20260528 for:【IM聊天-OA】列表页注册 IM 明细快照提供器----------- onUnmounted( registerImPageListProvider(() => { diff --git a/jeecgboot-vue3/src/layouts/default/index.vue b/jeecgboot-vue3/src/layouts/default/index.vue index 4c09370..de43a56 100644 --- a/jeecgboot-vue3/src/layouts/default/index.vue +++ b/jeecgboot-vue3/src/layouts/default/index.vue @@ -10,6 +10,12 @@ + + + + + + @@ -36,6 +42,12 @@ components: { LayoutFeatures: createAsyncComponent(() => import('/@/layouts/default/feature/index.vue')), LayoutFooter: createAsyncComponent(() => import('/@/layouts/default/footer/index.vue')), + //update-begin---author:GHT ---date:2026-05-29 for:【QH-MES审批流设计】全局发起审批悬浮按钮----- + ApprovalLaunchFloat: createAsyncComponent(() => import('/@/components/ApprovalLaunch/index.vue')), + //update-end---author:GHT ---date:2026-05-29 for:【QH-MES审批流设计】全局发起审批悬浮按钮----- + //update-begin---author:GHT ---date:2026-05-29 for:【QH-MES审批流设计】全局审批流程设计悬浮按钮----- + ApprovalDesignFloat: createAsyncComponent(() => import('/@/components/ApprovalDesign/index.vue')), + //update-end---author:GHT ---date:2026-05-29 for:【QH-MES审批流设计】全局审批流程设计悬浮按钮----- LayoutHeader, LayoutContent, LayoutSideBar, diff --git a/jeecgboot-vue3/src/utils/flowApiRecorder.ts b/jeecgboot-vue3/src/utils/flowApiRecorder.ts new file mode 100644 index 0000000..d437d7f --- /dev/null +++ b/jeecgboot-vue3/src/utils/flowApiRecorder.ts @@ -0,0 +1,94 @@ +import { ref } from 'vue'; + +/** + * 审批流「回调接口」录制器。 + * + * 审批流设计器是覆盖在业务页面之上的(同一前端应用、同一个 defHttp 实例), + * 录制时全局拦截器会把每次业务请求的 url/method/参数 上报到这里, + * 捕获用户点击目标按钮所触发的真实请求,自动回填到节点的回调接口配置。 + * + * @author GHT + * @date 2026-05-29 for:【QH-MES审批流设计】可视化录制业务按钮接口 + */ + +export interface FlowCapturedApi { + /** 请求路径(servlet 内路径,如 /xslmes/xxx/approve) */ + url: string; + /** 请求方法 GET/POST/PUT/DELETE */ + method: string; + /** 请求体(POST/PUT 时) */ + data?: any; + /** 查询参数 */ + params?: any; +} + +/** 录制中状态(供设计器隐藏遮罩、显示录制条) */ +export const flowApiRecording = ref(false); + +let resolver: ((c: FlowCapturedApi | null) => void) | null = null; + +/** 开始录制,返回 Promise;捕获到请求或取消时 resolve(取消为 null) */ +export function startFlowApiRecord(): Promise { + // 若已有未完成的录制,先取消 + cancelFlowApiRecord(); + flowApiRecording.value = true; + return new Promise((resolve) => { + resolver = resolve; + }); +} + +/** 取消录制 */ +export function cancelFlowApiRecord() { + if (resolver) { + const r = resolver; + resolver = null; + flowApiRecording.value = false; + r(null); + } else { + flowApiRecording.value = false; + } +} + +/** + * 由全局请求拦截器调用,上报每次请求。 + * 仅在录制中生效,默认只捕获「非 GET」业务请求(按钮动作通常是写操作), + * 并跳过审批流自身接口,避免误捕获。 + */ +export function notifyFlowApiRecorder(config: any) { + if (!flowApiRecording.value || !resolver) { + return; + } + const method = String(config?.method || 'get').toUpperCase(); + if (method === 'GET') { + return; + } + const url = config?.url; + if (!url || typeof url !== 'string') { + return; + } + // 跳过审批流/审批办理自身接口 + if (url.includes('/approvalFlow') || url.includes('/approvalHandle') || url.includes('/sys/dict')) { + return; + } + const captured: FlowCapturedApi = { + url, + method, + data: safeClone(config?.data), + params: safeClone(config?.params), + }; + const r = resolver; + resolver = null; + flowApiRecording.value = false; + r(captured); +} + +function safeClone(v: any) { + if (v == null || typeof v !== 'object') { + return v; + } + try { + return JSON.parse(JSON.stringify(v)); + } catch { + return undefined; + } +} diff --git a/jeecgboot-vue3/src/utils/http/axios/index.ts b/jeecgboot-vue3/src/utils/http/axios/index.ts index 757affc..f8c0178 100644 --- a/jeecgboot-vue3/src/utils/http/axios/index.ts +++ b/jeecgboot-vue3/src/utils/http/axios/index.ts @@ -19,6 +19,9 @@ import { useI18n } from '/@/hooks/web/useI18n'; import { joinTimestamp, formatRequestDate } from './helper'; import { useUserStoreWithOut } from '/@/store/modules/user'; import { cloneDeep } from "lodash-es"; +//update-begin---author:GHT ---date:2026-05-29 for:【QH-MES审批流设计】回调接口录制----- +import { notifyFlowApiRecorder } from '/@/utils/flowApiRecorder'; +//update-end---author:GHT ---date:2026-05-29 for:【QH-MES审批流设计】回调接口录制----- const globSetting = useGlobSetting(); const urlPrefix = globSetting.urlPrefix; const { createMessage, createErrorModal } = useMessage(); @@ -90,6 +93,9 @@ const transform: AxiosTransform = { // 请求之前处理config beforeRequestHook: (config, options) => { + //update-begin---author:GHT ---date:2026-05-29 for:【QH-MES审批流设计】录制时捕获原始servlet路径(未加前缀/上下文)----- + notifyFlowApiRecorder({ url: config.url, method: config.method, data: config.data, params: config.params }); + //update-end---author:GHT ---date:2026-05-29 for:【QH-MES审批流设计】录制时捕获原始servlet路径(未加前缀/上下文)----- const { apiUrl, joinPrefix, joinParamsToUrl, formatDate, joinTime = true, urlPrefix } = options; // http开头的请求url,不加前缀 diff --git a/jeecgboot-vue3/src/views/approval/flow/ApprovalFlowList.vue b/jeecgboot-vue3/src/views/approval/flow/ApprovalFlowList.vue new file mode 100644 index 0000000..1299702 --- /dev/null +++ b/jeecgboot-vue3/src/views/approval/flow/ApprovalFlowList.vue @@ -0,0 +1,128 @@ + + + + diff --git a/jeecgboot-vue3/src/views/approval/flow/ApprovalFlowModal.vue b/jeecgboot-vue3/src/views/approval/flow/ApprovalFlowModal.vue new file mode 100644 index 0000000..799018a --- /dev/null +++ b/jeecgboot-vue3/src/views/approval/flow/ApprovalFlowModal.vue @@ -0,0 +1,51 @@ + + + + diff --git a/jeecgboot-vue3/src/views/approval/flow/approvalFlow.api.ts b/jeecgboot-vue3/src/views/approval/flow/approvalFlow.api.ts new file mode 100644 index 0000000..1112e37 --- /dev/null +++ b/jeecgboot-vue3/src/views/approval/flow/approvalFlow.api.ts @@ -0,0 +1,75 @@ +import { defHttp } from '/@/utils/http/axios'; + +/** + * 审批流设计 API + * @author GHT + * @date 2026-05-29 for:【QH-MES审批流设计】新增审批流可视化设计 + */ +enum Api { + list = '/xslmes/approvalFlow/list', + save = '/xslmes/approvalFlow/add', + edit = '/xslmes/approvalFlow/edit', + get = '/xslmes/approvalFlow/queryById', + saveDesign = '/xslmes/approvalFlow/saveDesign', + updateStatus = '/xslmes/approvalFlow/updateStatus', + delete = '/xslmes/approvalFlow/delete', + deleteBatch = '/xslmes/approvalFlow/deleteBatch', + // update-begin---author:GHT ---date:2026-05-29 for:【QH-MES审批流设计】当前页设计上下文----- + designContext = '/xslmes/approvalFlow/designContext', + // update-end---author:GHT ---date:2026-05-29 for:【QH-MES审批流设计】当前页设计上下文----- +} + +/** + * 分页列表查询 + */ +export const getApprovalFlowList = (params) => defHttp.get({ url: Api.list, params }); + +/** + * 新增/编辑基本信息 + */ +export const saveOrUpdateApprovalFlow = (params, isUpdate) => { + const url = isUpdate ? Api.edit : Api.save; + return defHttp.post({ url, params }); +}; + +/** + * 通过 id 查询(含流程设计 JSON) + */ +export const getApprovalFlowById = (params) => defHttp.get({ url: Api.get, params }); + +/** + * 保存流程设计(节点树 JSON) + */ +export const saveApprovalFlowDesign = (params) => defHttp.post({ url: Api.saveDesign, params }); + +/** + * 发布 / 停用 + */ +export const updateApprovalFlowStatus = (params) => defHttp.post({ url: Api.updateStatus, params }, { joinParamsToUrl: true }); + +/** + * 删除 + */ +export const deleteApprovalFlow = (params, handleSuccess) => { + return defHttp.delete({ url: Api.delete, data: params }, { joinParamsToUrl: true }).then(() => { + handleSuccess(); + }); +}; + +/** + * 批量删除 + */ +export const batchDeleteApprovalFlow = (params, handleSuccess) => { + return defHttp.delete({ url: Api.deleteBatch, data: params }, { joinParamsToUrl: true }).then(() => { + handleSuccess(); + }); +}; + +// update-begin---author:GHT ---date:2026-05-29 for:【QH-MES审批流设计】当前页设计上下文(解析阶段字段+取/建草稿流程)----- +/** + * 获取当前功能页的审批流设计上下文: + * 返回 { routePath, bizTable, bizTableName, stages[], flow }, + * stages 为识别到的阶段字段(校对/审核/审批/分发/抄送),flow 为可直接进入设计器的流程记录。 + */ +export const getApprovalDesignContext = (routePath: string) => defHttp.get({ url: Api.designContext, params: { routePath } }); +// update-end---author:GHT ---date:2026-05-29 for:【QH-MES审批流设计】当前页设计上下文(解析阶段字段+取/建草稿流程)----- diff --git a/jeecgboot-vue3/src/views/approval/flow/approvalFlow.data.ts b/jeecgboot-vue3/src/views/approval/flow/approvalFlow.data.ts new file mode 100644 index 0000000..f169ccb --- /dev/null +++ b/jeecgboot-vue3/src/views/approval/flow/approvalFlow.data.ts @@ -0,0 +1,112 @@ +import { BasicColumn, FormSchema } from '/@/components/Table'; + +/** + * 审批流设计 列表列 / 表单 schema + * @author GHT + * @date 2026-05-29 for:【QH-MES审批流设计】新增审批流可视化设计 + */ +export const columns: BasicColumn[] = [ + { + title: '审批流名称', + dataIndex: 'flowName', + align: 'left', + }, + { + title: '绑定单据', + dataIndex: 'bizTableName', + width: 200, + }, + { + title: '状态', + dataIndex: 'status_dictText', + width: 100, + }, + { + title: '备注', + dataIndex: 'remark', + align: 'left', + }, + { + title: '创建时间', + dataIndex: 'createTime', + width: 180, + }, +]; + +export const searchFormSchema: FormSchema[] = [ + { + field: 'flowName', + label: '审批流名称', + component: 'Input', + colProps: { span: 6 }, + }, + { + field: 'bizTable', + label: '绑定单据', + component: 'JDictSelectTag', + componentProps: { + dictCode: 'mes_xsl_approval_biz_doc', + }, + colProps: { span: 6 }, + }, + { + field: 'status', + label: '状态', + component: 'JDictSelectTag', + componentProps: { + dictCode: 'mes_xsl_approval_flow_status', + }, + colProps: { span: 6 }, + }, +]; + +export const formSchema: FormSchema[] = [ + { + label: '主键', + field: 'id', + component: 'Input', + show: false, + }, + { + field: 'flowName', + label: '审批流名称', + component: 'Input', + required: true, + componentProps: { + placeholder: '请输入审批流名称', + maxlength: 100, + }, + }, + { + field: 'bizTable', + label: '绑定单据', + component: 'JDictSelectTag', + required: true, + componentProps: { + dictCode: 'mes_xsl_approval_biz_doc', + placeholder: '请先选择需要审批的单据', + }, + // 编辑时禁止修改绑定单据,避免已设计的节点条件与单据字段错配 + dynamicDisabled: ({ values }) => !!values.id, + }, + { + field: 'titleField', + label: '单据标题字段', + component: 'Input', + helpMessage: '发起审批时用于展示具体单据的字段名(如 spec_name、code),不填则只显示单据ID', + componentProps: { + placeholder: '选填,业务表中用于展示的字段名,如 spec_name', + maxlength: 100, + }, + }, + { + field: 'remark', + label: '备注', + component: 'InputTextArea', + componentProps: { + placeholder: '请输入备注', + maxlength: 500, + rows: 3, + }, + }, +]; diff --git a/jeecgboot-vue3/src/views/approval/flow/approvalHandle.api.ts b/jeecgboot-vue3/src/views/approval/flow/approvalHandle.api.ts new file mode 100644 index 0000000..7a5b98f --- /dev/null +++ b/jeecgboot-vue3/src/views/approval/flow/approvalHandle.api.ts @@ -0,0 +1,25 @@ +import { defHttp } from '/@/utils/http/axios'; + +/** + * 审批办理/流转 API(供 IM 审批卡片按钮调用) + * @author GHT + * @date 2026-05-29 for:【QH-MES审批流设计】审批办理/流转 + */ +enum Api { + detail = '/xslmes/approvalHandle/detail', + status = '/xslmes/approvalHandle/status', + approve = '/xslmes/approvalHandle/approve', + reject = '/xslmes/approvalHandle/reject', +} + +/** 查看单据全部字段 + 审批进度/历史 */ +export const getApprovalDetail = (instanceId: string) => defHttp.get({ url: Api.detail, params: { instanceId } }); + +/** 轻量实时状态:用于卡片判断是否仍可办理(旧节点卡片置灰) */ +export const getApprovalStatus = (instanceId: string) => defHttp.get({ url: Api.status, params: { instanceId } }); + +/** 审批通过(按节点 multiMode 流转到下一处理人/节点) */ +export const approveApproval = (params: { instanceId: string; comment?: string }) => defHttp.post({ url: Api.approve, data: params }); + +/** 驳回(需填写理由) */ +export const rejectApproval = (params: { instanceId: string; reason: string }) => defHttp.post({ url: Api.reject, data: params }); diff --git a/jeecgboot-vue3/src/views/approval/flow/components/FlowDesign.vue b/jeecgboot-vue3/src/views/approval/flow/components/FlowDesign.vue new file mode 100644 index 0000000..ea2d994 --- /dev/null +++ b/jeecgboot-vue3/src/views/approval/flow/components/FlowDesign.vue @@ -0,0 +1,189 @@ + + + + + + diff --git a/jeecgboot-vue3/src/views/approval/flow/components/FlowNode.vue b/jeecgboot-vue3/src/views/approval/flow/components/FlowNode.vue new file mode 100644 index 0000000..79566b2 --- /dev/null +++ b/jeecgboot-vue3/src/views/approval/flow/components/FlowNode.vue @@ -0,0 +1,99 @@ + + + + diff --git a/jeecgboot-vue3/src/views/approval/flow/components/NodeConfigDrawer.vue b/jeecgboot-vue3/src/views/approval/flow/components/NodeConfigDrawer.vue new file mode 100644 index 0000000..6d8c875 --- /dev/null +++ b/jeecgboot-vue3/src/views/approval/flow/components/NodeConfigDrawer.vue @@ -0,0 +1,359 @@ + + + + + + + + + diff --git a/jeecgboot-vue3/src/views/approval/flow/components/flow.less b/jeecgboot-vue3/src/views/approval/flow/components/flow.less new file mode 100644 index 0000000..49bae96 --- /dev/null +++ b/jeecgboot-vue3/src/views/approval/flow/components/flow.less @@ -0,0 +1,428 @@ +/** + * 钉钉式审批流设计器 样式 + * @author GHT + * @date 2026-05-29 for:【QH-MES审批流设计】新增审批流可视化设计 + */ +@line-color: #cacaca; + +/* 录制回调接口时,隐藏整个设计器弹窗(含遮罩),让用户能点击业务页面上的真实按钮 */ +.flow-design-recording-hide { + display: none !important; +} + +.fd-design { + display: flex; + flex-direction: column; + height: 100%; +} + +.fd-toolbar { + flex-shrink: 0; + padding: 10px 16px; + background: #fff; + border-bottom: 1px solid #f0f0f0; + + .fd-tb-item { + margin-right: 24px; + font-size: 14px; + color: #333; + } + + .fd-tb-tip { + color: #999; + font-size: 12px; + } +} + +/* update-begin---author:GHT ---date:2026-05-29 for:【QH-MES审批流设计】候选阶段侧边栏布局----- */ +.fd-body { + flex: 1; + display: flex; + min-height: 0; +} + +.fd-palette { + flex-shrink: 0; + width: 220px; + overflow-y: auto; + background: #fff; + border-right: 1px solid #f0f0f0; + padding: 14px 12px; + + .fd-palette-title { + font-size: 14px; + font-weight: 600; + color: #333; + margin-bottom: 6px; + } + + .fd-palette-tip { + font-size: 12px; + color: #999; + line-height: 1.5; + margin-bottom: 12px; + } + + .fd-palette-list { + display: flex; + flex-direction: column; + gap: 10px; + } + + .fd-palette-item { + position: relative; + border: 1px solid #e8e8e8; + border-radius: 6px; + padding: 8px 30px 8px 10px; + cursor: pointer; + transition: all 0.15s; + border-left: 3px solid #ff943e; + + &.fd-palette-cc { + border-left-color: #3296fa; + } + + .fd-palette-item-name { + font-size: 13px; + color: #333; + font-weight: 500; + } + + .fd-palette-item-field { + font-size: 12px; + color: #999; + margin-top: 2px; + word-break: break-all; + } + + .fd-palette-item-add { + position: absolute; + right: 10px; + top: 50%; + transform: translateY(-50%); + color: #3296fa; + font-size: 16px; + } + + &:hover { + border-color: #3296fa; + background: #f0f7ff; + box-shadow: 0 2px 8px rgba(50, 150, 250, 0.15); + } + } +} +/* update-end---author:GHT ---date:2026-05-29 for:【QH-MES审批流设计】候选阶段侧边栏布局----- */ + +.fd-canvas { + flex: 1; + overflow: auto; + background: #f0f2f5; + padding: 40px 20px 80px; +} + +.fd-flow { + display: inline-flex; + flex-direction: column; + align-items: center; + min-width: 100%; +} + +/* ---------- 节点列 ---------- */ +.fd-node { + display: flex; + flex-direction: column; + align-items: center; + flex-grow: 1; + position: relative; +} + +.fd-card-wrap { + position: relative; +} + +.fd-card { + position: relative; + width: 220px; + background: #fff; + border-radius: 8px; + box-shadow: 0 1px 5px rgba(0, 0, 0, 0.12); + cursor: pointer; + transition: box-shadow 0.2s; + + &:hover { + box-shadow: 0 2px 12px rgba(0, 0, 0, 0.2); + + .fd-del { + display: inline-flex; + } + } + + &.fd-error { + box-shadow: 0 0 0 1px #ff4d4f; + } +} + +.fd-card-header { + display: flex; + align-items: center; + padding: 8px 12px; + font-size: 13px; + color: #fff; + border-radius: 8px 8px 0 0; + + .fd-title { + flex: 1; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + .fd-priority { + font-size: 12px; + opacity: 0.85; + margin-right: 4px; + } + + .fd-del { + display: none; + cursor: pointer; + font-size: 12px; + opacity: 0.85; + + &:hover { + opacity: 1; + } + } +} + +.fd-card-body { + padding: 12px; + font-size: 13px; + color: #555; + min-height: 22px; + line-height: 1.5; + word-break: break-all; + + &.fd-placeholder { + color: #f56c6c; + } +} + +/* 各类型节点头部配色 */ +.fd-start .fd-card-header { + background: #576a95; +} +.fd-approver .fd-card-header { + background: #ff943e; +} +.fd-cc .fd-card-header { + background: #3296fa; +} +.fd-branch { + .fd-card { + width: 220px; + } + .fd-card-header { + background: #fff; + color: #15bca3; + border-bottom: 1px solid #f0f0f0; + + .fd-priority { + color: #999; + } + .fd-del { + color: #999; + } + } +} + +/* ---------- 节点之间的连线 + 加号 ---------- */ +.fd-add { + position: relative; + width: 2px; + min-height: 70px; + background: @line-color; + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; +} + +.fd-add-btn { + position: relative; + z-index: 1; + width: 30px; + height: 30px; + border: none; + border-radius: 50%; + background: #3296fa; + color: #fff; + font-size: 16px; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + box-shadow: 0 2px 6px rgba(50, 150, 250, 0.35); + transition: transform 0.15s; + + &:hover { + transform: scale(1.12); + } +} + +/* 加号弹出菜单 */ +.fd-add-menu { + display: flex; + gap: 12px; + padding: 4px; +} +.fd-add-item { + width: 76px; + height: 70px; + border: 1px solid #f0f0f0; + border-radius: 6px; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 6px; + cursor: pointer; + font-size: 13px; + color: #333; + transition: all 0.15s; + + .anticon { + font-size: 20px; + } + + &:hover { + border-color: #3296fa; + color: #3296fa; + background: #f0f7ff; + } + + &.fd-add-approver .anticon { + color: #ff943e; + } + &.fd-add-cc .anticon { + color: #3296fa; + } + &.fd-add-condition .anticon { + color: #15bca3; + } +} + +/* ---------- 条件分支 ---------- */ +.fd-branches { + position: relative; + display: flex; + flex-direction: column; + align-items: center; +} + +/* +条件 按钮(带进入竖线) */ +.fd-add-branch-wrap { + position: relative; + width: 100%; + height: 46px; + display: flex; + justify-content: center; + align-items: center; + + &::before { + content: ''; + position: absolute; + top: 0; + left: 50%; + transform: translateX(-50%); + width: 2px; + height: 100%; + background: @line-color; + } + + .fd-add-branch { + position: relative; + z-index: 1; + border-radius: 14px; + } +} + +.fd-branch-cols { + display: flex; + position: relative; +} + +.fd-branch-col { + position: relative; + display: flex; + flex-direction: column; + align-items: center; + background: #f0f2f5; + border-top: 2px solid @line-color; + border-bottom: 2px solid @line-color; + padding: 0 24px; + + /* 列内顶部进入竖线 + 底部汇出竖线 */ + .fd-branch-inner { + position: relative; + padding-top: 30px; + padding-bottom: 0; + + &::before { + content: ''; + position: absolute; + top: 0; + left: 50%; + transform: translateX(-50%); + width: 2px; + height: 30px; + background: @line-color; + } + } +} + +/* 首末列外侧白块,遮挡多余横线,形成包裹效果 */ +.fd-cover { + position: absolute; + width: 50%; + height: 4px; + background: #f0f2f5; + z-index: 2; +} +.fd-cover-tl { + top: -2px; + left: -1px; +} +.fd-cover-bl { + bottom: -2px; + left: -1px; +} +.fd-cover-tr { + top: -2px; + right: -1px; +} +.fd-cover-br { + bottom: -2px; + right: -1px; +} + +/* ---------- 结束节点 ---------- */ +.fd-end { + display: flex; + flex-direction: column; + align-items: center; + padding-top: 0; + + .fd-end-dot { + width: 10px; + height: 10px; + border-radius: 50%; + background: #dedede; + margin-bottom: 6px; + } + + span { + font-size: 13px; + color: #999; + } +} diff --git a/jeecgboot-vue3/src/views/approval/flow/components/flowTypes.ts b/jeecgboot-vue3/src/views/approval/flow/components/flowTypes.ts new file mode 100644 index 0000000..d0d8dfa --- /dev/null +++ b/jeecgboot-vue3/src/views/approval/flow/components/flowTypes.ts @@ -0,0 +1,267 @@ +/** + * 钉钉式审批流 节点数据模型 + 工厂函数 + * @author GHT + * @date 2026-05-29 for:【QH-MES审批流设计】新增审批流可视化设计 + */ + +// 节点类型:start发起人 approver审批人 cc抄送人 condition条件路由 branch条件分支 +export type NodeType = 'start' | 'approver' | 'cc' | 'condition' | 'branch'; + +export interface FlowNode { + id: string; + type: NodeType; + name: string; + // 节点配置(按 type 不同含义不同) + props: Record; + // 链式下一个节点 + childNode?: FlowNode | null; + // 仅 condition 节点:分支数组(每个元素 type=branch) + conditionNodes?: FlowNode[]; +} + +let _seed = 0; + +/** 生成唯一节点 id */ +export function nid(prefix = 'node'): string { + _seed += 1; + return `${prefix}_${Date.now().toString(36)}_${_seed}`; +} + +/** 发起人节点(根节点,固定存在) */ +export function createStartNode(): FlowNode { + return { + id: nid('start'), + type: 'start', + name: '发起人', + props: { + // initiatorType: all全员 / user指定成员 / role指定角色 + initiatorType: 'all', + userText: '', + roleList: [], + }, + childNode: null, + }; +} + +/** 审批人节点 */ +export function createApproverNode(): FlowNode { + return { + id: nid('approver'), + type: 'approver', + name: '审批人', + props: { + // approverType: user指定成员 / role指定角色 / leader主管 / self发起人自己 + approverType: 'user', + userText: '', + roleList: [], + leaderLevel: 1, + // 多人审批方式 and会签(需全部同意) / or或签(一人同意) / sequence依次审批 + multiMode: 'and', + // 审批人为空时 pass自动通过 / admin转交管理员 / stop终止 + emptyStrategy: 'admin', + }, + childNode: null, + }; +} + +/** 抄送人节点 */ +export function createCcNode(): FlowNode { + return { + id: nid('cc'), + type: 'cc', + name: '抄送人', + props: { + // ccType: user指定成员/角色 / field取单据字段中的人员 + ccType: 'user', + userText: '', + roleList: [], + allowEditCc: false, + }, + childNode: null, + }; +} + +// update-begin---author:GHT ---date:2026-05-29 for:【QH-MES审批流设计】按当前页解析的字段生成审批阶段节点----- +/** 解析出的页面阶段字段 */ +export interface StageField { + stageKey: string; + stageName: string; + nodeType: 'approver' | 'cc'; + field: string; + fieldComment?: string; +} + +/** + * 由"当前页字段"生成阶段节点: + * 审批阶段(校对/审核/审批/分发) -> 审批人节点,处理人=取单据该字段中的人员; + * 抄送阶段 -> 抄送节点,抄送人=取单据该字段中的人员。 + */ +export function createStageNode(stage: StageField): FlowNode { + const fieldLabel = stage.fieldComment || stage.field; + if (stage.nodeType === 'cc') { + const node = createCcNode(); + node.name = stage.stageName; + node.props.ccType = 'field'; + node.props.fieldName = stage.field; + node.props.fieldLabel = fieldLabel; + return node; + } + const node = createApproverNode(); + node.name = stage.stageName; + node.props.approverType = 'field'; + node.props.fieldName = stage.field; + node.props.fieldLabel = fieldLabel; + return node; +} +// update-end---author:GHT ---date:2026-05-29 for:【QH-MES审批流设计】按当前页解析的字段生成审批阶段节点----- + +/** 条件分支节点(默认两条分支:条件 + 其它情况) */ +export function createConditionNode(): FlowNode { + return { + id: nid('condition'), + type: 'condition', + name: '条件分支', + props: {}, + conditionNodes: [createBranchNode(1), createBranchNode(2, true)], + childNode: null, + }; +} + +/** 单个条件分支 */ +export function createBranchNode(priority: number, isDefault = false): FlowNode { + return { + id: nid('branch'), + type: 'branch', + name: isDefault ? '其它情况' : `条件${priority}`, + props: { + priorityLevel: priority, + isDefault, + // 条件列表:{ field 字段名, label 字段中文, operator 运算符, value 值 } + conditions: [] as any[], + // 多条件关系 and / or + logic: 'and', + }, + childNode: null, + }; +} + +/** 运算符选项 */ +export const OPERATOR_OPTIONS = [ + { label: '等于', value: 'eq' }, + { label: '不等于', value: 'ne' }, + { label: '大于', value: 'gt' }, + { label: '大于等于', value: 'gte' }, + { label: '小于', value: 'lt' }, + { label: '小于等于', value: 'lte' }, + { label: '包含', value: 'contains' }, + { label: '为空', value: 'empty' }, + { label: '不为空', value: 'notEmpty' }, +]; + +/** 节点卡片内容摘要文本 */ +export function nodeSummary(node: FlowNode): string { + if (node.type === 'start') { + const t = node.props.initiatorType; + if (t === 'all') return '所有人可发起'; + if (t === 'user') return node.props.userText ? `指定成员:${node.props.userText}` : '请设置发起人'; + if (t === 'role') return node.props.roleList?.length ? `指定角色(${node.props.roleList.length})` : '请设置发起角色'; + return '请设置发起人'; + } + if (node.type === 'approver') { + const t = node.props.approverType; + if (t === 'self') return '发起人自己'; + if (t === 'leader') return `第${node.props.leaderLevel || 1}级主管`; + if (t === 'role') return node.props.roleList?.length ? `角色审批(${node.props.roleList.length})` : '请设置审批角色'; + // update-begin---author:GHT ---date:2026-05-29 for:【QH-MES审批流设计】取单据字段审批人摘要----- + if (t === 'field') return `取单据字段:${node.props.fieldLabel || node.props.fieldName || '未指定字段'}`; + // update-end---author:GHT ---date:2026-05-29 for:【QH-MES审批流设计】取单据字段审批人摘要----- + return node.props.userText ? `指定成员:${node.props.userText}` : '请设置审批人'; + } + if (node.type === 'cc') { + // update-begin---author:GHT ---date:2026-05-29 for:【QH-MES审批流设计】取单据字段抄送人摘要----- + if (node.props.ccType === 'field') return `抄送单据字段:${node.props.fieldLabel || node.props.fieldName || '未指定字段'}`; + // update-end---author:GHT ---date:2026-05-29 for:【QH-MES审批流设计】取单据字段抄送人摘要----- + return node.props.userText ? `抄送:${node.props.userText}` : '请设置抄送人'; + } + if (node.type === 'branch') { + if (node.props.isDefault) return '未满足其它条件时进入此分支'; + const list = node.props.conditions || []; + if (!list.length) return '请设置条件'; + return list.map((c: any) => `${c.label || c.field} ${operatorText(c.operator)} ${c.value ?? ''}`).join(node.props.logic === 'or' ? ' 或 ' : ' 且 '); + } + return ''; +} + +function operatorText(op: string): string { + return OPERATOR_OPTIONS.find((o) => o.value === op)?.label || op; +} + +/** 深度遍历每个节点(含分支) */ +export function eachNode(node: FlowNode | null | undefined, cb: (n: FlowNode) => void) { + if (!node) return; + cb(node); + if (node.childNode) eachNode(node.childNode, cb); + if (node.conditionNodes) node.conditionNodes.forEach((b) => eachNode(b, cb)); +} + +/** 在 prevId 节点之后插入新节点 */ +export function insertAfter(root: FlowNode, prevId: string, newNode: FlowNode) { + eachNode(root, (n) => { + if (n.id === prevId && newNode.id !== prevId) { + newNode.childNode = n.childNode || null; + n.childNode = newNode; + } + }); +} + +/** 删除节点(普通链节点 / 条件分支,发起人不可删) */ +export function removeNode(root: FlowNode, id: string): boolean { + // 1) 链式节点:找到以其为 childNode 的父节点 + let parent: FlowNode | null = null; + eachNode(root, (n) => { + if (n.childNode && n.childNode.id === id) parent = n; + }); + if (parent) { + (parent as FlowNode).childNode = (parent as FlowNode).childNode?.childNode || null; + return true; + } + // 2) 条件分支:找到包含该分支的 condition + let cond: FlowNode | null = null; + eachNode(root, (n) => { + if (n.conditionNodes && n.conditionNodes.some((b) => b.id === id)) cond = n; + }); + if (cond) { + const c = cond as FlowNode; + const arr = c.conditionNodes as FlowNode[]; + if (arr.length <= 2) { + // 只剩两条分支时删除一条 => 整个条件节点收起,保留其后续链 + let condParent: FlowNode | null = null; + eachNode(root, (n) => { + if (n.childNode && n.childNode.id === c.id) condParent = n; + }); + if (condParent) (condParent as FlowNode).childNode = c.childNode || null; + } else { + const i = arr.findIndex((b) => b.id === id); + if (i >= 0) arr.splice(i, 1); + } + return true; + } + return false; +} + +/** 给条件节点新增一条分支 */ +export function addBranch(root: FlowNode, conditionId: string) { + eachNode(root, (n) => { + if (n.id === conditionId && n.conditionNodes) { + const next = n.conditionNodes.length + 1; + // 新分支插入到"其它情况"默认分支之前 + const defaultIdx = n.conditionNodes.findIndex((b) => b.props.isDefault); + const branch = createBranchNode(next); + if (defaultIdx >= 0) { + n.conditionNodes.splice(defaultIdx, 0, branch); + } else { + n.conditionNodes.push(branch); + } + } + }); +} diff --git a/jeecgboot-vue3/src/views/approval/flow/launch.api.ts b/jeecgboot-vue3/src/views/approval/flow/launch.api.ts new file mode 100644 index 0000000..3723adf --- /dev/null +++ b/jeecgboot-vue3/src/views/approval/flow/launch.api.ts @@ -0,0 +1,34 @@ +import { defHttp } from '/@/utils/http/axios'; + +/** + * 发起审批 API(全局悬浮按钮使用) + * @author GHT + * @date 2026-05-29 for:【QH-MES审批流设计】发起审批运行时 + */ +enum Api { + publishedList = '/xslmes/approvalLaunch/publishedList', + bizRecords = '/xslmes/approvalLaunch/bizRecords', + launch = '/xslmes/approvalLaunch/launch', + launchBatch = '/xslmes/approvalLaunch/launchBatch', +} + +/** + * 已发布审批流列表(可发起的单据类型) + */ +export const getPublishedFlows = () => defHttp.get({ url: Api.publishedList }); + +/** + * 根据审批流查询其绑定单据的记录列表 + */ +export const getBizRecords = (params: { flowId: string; keyword?: string }) => defHttp.get({ url: Api.bizRecords, params }); + +/** + * 发起审批(单条) + */ +export const launchApproval = (params: { flowId: string; bizDataId: string; bizTitle?: string }) => defHttp.post({ url: Api.launch, params }); + +/** + * 批量发起审批(列表多选) + */ +export const launchApprovalBatch = (params: { flowId: string; items: { bizDataId: string; bizTitle?: string }[] }) => + defHttp.post({ url: Api.launchBatch, params }); diff --git a/jeecgboot-vue3/src/views/system/im/ImApprovalDetailModal.vue b/jeecgboot-vue3/src/views/system/im/ImApprovalDetailModal.vue new file mode 100644 index 0000000..b668682 --- /dev/null +++ b/jeecgboot-vue3/src/views/system/im/ImApprovalDetailModal.vue @@ -0,0 +1,154 @@ + + + + + + diff --git a/jeecgboot-vue3/src/views/system/im/ImBizRecordMessageContent.vue b/jeecgboot-vue3/src/views/system/im/ImBizRecordMessageContent.vue index 03458b2..2e5fb9e 100644 --- a/jeecgboot-vue3/src/views/system/im/ImBizRecordMessageContent.vue +++ b/jeecgboot-vue3/src/views/system/im/ImBizRecordMessageContent.vue @@ -1,442 +1,426 @@ - - - - - diff --git a/jeecgboot-vue3/src/views/system/im/ImChat.vue b/jeecgboot-vue3/src/views/system/im/ImChat.vue index f1082c5..0d50477 100644 --- a/jeecgboot-vue3/src/views/system/im/ImChat.vue +++ b/jeecgboot-vue3/src/views/system/im/ImChat.vue @@ -175,6 +175,7 @@ :payload="getBizRecordPayload(msg.content)!" :mine="msg.mine" :receiver-has-biz-page-permission="msg.receiverHasBizPagePermission" + @handled="handleApprovalHandled" />
{{ msg.content }}
{{ formatTime(msg.createTime) }}
@@ -1376,6 +1377,19 @@ //update-end---author:xsl ---date:20260528 for:【IM聊天】收到新消息时:靠近底部则自动滚底,否则显示新消息提示----------- } + //update-begin---author:GHT ---date:2026-05-29 for:【QH-MES审批流设计】审批办理后即时刷新当前会话,连续节点同一处理人无需关闭重开----- + // 审批卡片办理(通过/驳回)成功后:下一节点卡片或结果通知已由后端同步发出并入库, + // 此处强制刷新当前会话消息,使新卡片立即出现在当前聊天窗口(不依赖 WS 推送时序)。 + async function handleApprovalHandled() { + if (!activeConversationId.value) { + return; + } + await loadMessages(true, { forceRefresh: true }); + await nextTick(); + scrollToBottom(); + } + //update-end---author:GHT ---date:2026-05-29 for:【QH-MES审批流设计】审批办理后即时刷新当前会话,连续节点同一处理人无需关闭重开----- + function handleImChatSocketUi(data: Record) { if (data.cmd !== 'chat') { return; diff --git a/jeecgboot-vue3/src/views/system/im/imBizRecordMessage.ts b/jeecgboot-vue3/src/views/system/im/imBizRecordMessage.ts index 8542fae..08232f3 100644 --- a/jeecgboot-vue3/src/views/system/im/imBizRecordMessage.ts +++ b/jeecgboot-vue3/src/views/system/im/imBizRecordMessage.ts @@ -16,6 +16,16 @@ export interface ImBizRecordItem { /** v2:表格字段 */ fields?: ImBizRecordField[]; linkPath: string; + // update-begin---author:GHT ---date:2026-05-29 for:【QH-MES审批流设计】审批卡片办理扩展字段----- + /** 审批实例ID(存在则为审批卡片,展示办理按钮) */ + instanceId?: string; + /** 当前节点办理按钮文案(如 审批/审核/批准),存在且 canApprove 时展示 */ + actionLabel?: string; + /** 接收人是否可办理(当前活动处理人 且 审批中) */ + canApprove?: boolean; + /** 该卡片对应的节点ID,用于实时判断是否仍为当前节点(旧节点卡片置灰) */ + nodeId?: string; + // update-end---author:GHT ---date:2026-05-29 for:【QH-MES审批流设计】审批卡片办理扩展字段----- } export interface ImBizRecordPayload {