2 Commits

33 changed files with 1617 additions and 250 deletions

View File

@@ -0,0 +1,38 @@
package org.jeecg.modules.xslmes.approval.action;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
/**
* 审批联动业务动作标注。
*
* <p>把本注解打在业务 Controller 的处理方法上,即声明「该按钮/接口可被审批流程作为回调动作选择」。
* 系统启动时会反射扫描所有带本注解的接口方法,取其 {@code @RequestMapping} 真实路径与 HTTP 方法,
* 按 {@link #table()} 归类,供审批流设计器的节点「回调接口」下拉选择。</p>
*
* <p>因为基于 Spring 运行时反射 + 真实映射路径生产环境天然可用URL 绝对准确,无需任何源码解析。</p>
*
* @author GHT
* @date 2026-05-29 for【QH-MES审批流设计】审批联动业务动作注解
*/
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface ApprovalBizAction {
/** 动作展示名(如「批准」「反审核」「启用」),供设计器下拉显示 */
String name();
/** 所属业务表名(如 mes_xsl_rubber_quick_test_std与审批流绑定的 bizTable 对应 */
String table();
/**
* 适用触发时机,可多选;为空表示三种时机均可选用。
* 取值onNodeApprove(本节点通过) / onApprove(最终通过) / onReject(驳回)
*/
String[] phase() default {};
/** 排序,越小越靠前 */
int order() default 0;
}

View File

@@ -0,0 +1,154 @@
package org.jeecg.modules.xslmes.approval.action;
import lombok.extern.slf4j.Slf4j;
import org.apache.shiro.authz.annotation.RequiresPermissions;
import org.springframework.context.ApplicationContext;
import org.springframework.context.event.ContextRefreshedEvent;
import org.springframework.context.event.EventListener;
import org.springframework.stereotype.Component;
import org.springframework.web.method.HandlerMethod;
import org.springframework.web.servlet.mvc.condition.RequestMethodsRequestCondition;
import org.springframework.web.servlet.mvc.method.RequestMappingInfo;
import org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerMapping;
import jakarta.annotation.Resource;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Comparator;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
/**
* 审批联动业务动作注册表。
*
* <p>容器启动完成后,反射扫描所有带 {@link ApprovalBizAction} 的接口方法,
* 取真实映射路径 + HTTP 方法,按业务表归类缓存,供设计器查询选择。</p>
*
* @author GHT
* @date 2026-05-29 for【QH-MES审批流设计】审批联动业务动作注册表
*/
@Slf4j
@Component
public class ApprovalBizActionRegistry {
@Resource
private ApplicationContext applicationContext;
/** 业务表 -> 动作列表 */
private final Map<String, List<ApprovalBizActionVo>> byTable = new ConcurrentHashMap<>();
@EventListener(ContextRefreshedEvent.class)
public void onContextRefreshed() {
try {
scan();
} catch (Exception e) {
log.warn("[审批联动] 扫描 @ApprovalBizAction 失败", e);
}
}
private synchronized void scan() {
byTable.clear();
RequestMappingHandlerMapping mapping;
try {
mapping = applicationContext.getBean(RequestMappingHandlerMapping.class);
} catch (Exception e) {
log.warn("[审批联动] 未取到 RequestMappingHandlerMapping跳过扫描", e);
return;
}
Map<RequestMappingInfo, HandlerMethod> handlerMethods = mapping.getHandlerMethods();
for (Map.Entry<RequestMappingInfo, HandlerMethod> entry : handlerMethods.entrySet()) {
HandlerMethod hm = entry.getValue();
ApprovalBizAction ann = hm.getMethodAnnotation(ApprovalBizAction.class);
if (ann == null || ann.table() == null || ann.table().isEmpty()) {
continue;
}
RequestMappingInfo info = entry.getKey();
String url = resolveUrl(info);
if (url == null || url.isEmpty()) {
continue;
}
ApprovalBizActionVo vo = new ApprovalBizActionVo();
vo.setName(ann.name());
vo.setTable(ann.table());
vo.setPhase(ann.phase());
vo.setOrder(ann.order());
vo.setUrl(url);
vo.setMethod(resolveMethod(info));
vo.setPerms(resolvePerms(hm));
byTable.computeIfAbsent(ann.table(), k -> new ArrayList<>()).add(vo);
}
// 排序
for (List<ApprovalBizActionVo> list : byTable.values()) {
list.sort(Comparator.comparingInt(ApprovalBizActionVo::getOrder));
}
int total = byTable.values().stream().mapToInt(List::size).sum();
log.info("[审批联动] 已注册业务动作 {} 个,覆盖 {} 张业务表", total, byTable.size());
}
/** 取第一个映射路径(不含 context-path) */
private String resolveUrl(RequestMappingInfo info) {
Set<String> patterns = info.getPatternValues();
if (patterns == null || patterns.isEmpty()) {
return null;
}
return patterns.iterator().next();
}
/** 取 HTTP 方法,默认 POST */
private String resolveMethod(RequestMappingInfo info) {
RequestMethodsRequestCondition cond = info.getMethodsCondition();
if (cond == null || cond.getMethods().isEmpty()) {
return "POST";
}
return cond.getMethods().iterator().next().name();
}
/** 取接口权限标识(@RequiresPermissions) */
private String resolvePerms(HandlerMethod hm) {
RequiresPermissions rp = hm.getMethodAnnotation(RequiresPermissions.class);
if (rp != null && rp.value().length > 0) {
return rp.value()[0];
}
return null;
}
/** 按业务表取可选动作 */
public List<ApprovalBizActionVo> getByTable(String table) {
if (table == null) {
return Collections.emptyList();
}
List<ApprovalBizActionVo> list = byTable.get(table);
return list == null ? Collections.emptyList() : new ArrayList<>(list);
}
//update-begin---author:GHT ---date:2026-05-29 for【QH-MES审批流设计】驳回统一回退按表+时机自动取业务动作-----------
/**
* 按业务表 + 时机取动作(如驳回统一执行 onReject 动作,无需在每个流程节点手动配置)。
*/
public List<ApprovalBizActionVo> getByTableAndPhase(String table, String phase) {
if (table == null || phase == null) {
return Collections.emptyList();
}
List<ApprovalBizActionVo> list = byTable.get(table);
if (list == null || list.isEmpty()) {
return Collections.emptyList();
}
List<ApprovalBizActionVo> result = new ArrayList<>();
for (ApprovalBizActionVo vo : list) {
String[] phases = vo.getPhase();
if (phases == null) {
continue;
}
for (String p : phases) {
if (phase.equals(p)) {
result.add(vo);
break;
}
}
}
return result;
}
//update-end---author:GHT ---date:2026-05-29 for【QH-MES审批流设计】驳回统一回退按表+时机自动取业务动作-----------
}

View File

@@ -0,0 +1,38 @@
package org.jeecg.modules.xslmes.approval.action;
import lombok.Data;
import java.io.Serializable;
/**
* 审批联动业务动作(供设计器节点「回调接口」下拉选择)。
*
* @author GHT
* @date 2026-05-29 for【QH-MES审批流设计】审批联动业务动作VO
*/
@Data
public class ApprovalBizActionVo implements Serializable {
private static final long serialVersionUID = 1L;
/** 动作名(如「批准」) */
private String name;
/** 接口真实路径(取自 @RequestMapping不含 context-path如 /xslmes/xxx/updateStatus */
private String url;
/** HTTP 方法GET/POST/PUT/DELETE */
private String method;
/** 所属业务表 */
private String table;
/** 适用时机onNodeApprove/onApprove/onReject空表示均可 */
private String[] phase;
/** 接口权限标识(取自 @RequiresPermissions可空 */
private String perms;
/** 排序 */
private int order;
}

View File

@@ -50,6 +50,27 @@ public class ApprovalActionHttpExecutor {
* @param phase 时机onNodeApprove / onApprove / onReject
* @param inst 审批实例
*/
//update-begin---author:GHT ---date:2026-05-29 for【QH-MES审批流设计】判断节点某阶段是否配置了业务回调-----------
/**
* 判断节点在指定时机是否配置了回调接口(用于决定是否由业务回调全权负责回退)。
*/
public boolean hasActions(JSONObject node, String phase) {
if (node == null) {
return false;
}
JSONObject propsObj = node.getJSONObject("props");
if (propsObj == null) {
return false;
}
JSONObject callbackActions = propsObj.getJSONObject("callbackActions");
if (callbackActions == null) {
return false;
}
JSONArray actions = callbackActions.getJSONArray(phase);
return actions != null && !actions.isEmpty();
}
//update-end---author:GHT ---date:2026-05-29 for【QH-MES审批流设计】判断节点某阶段是否配置了业务回调-----------
public void run(JSONObject node, String phase, MesXslApprovalInstance inst) {
if (node == null || inst == null) {
return;
@@ -86,6 +107,25 @@ public class ApprovalActionHttpExecutor {
}
}
//update-begin---author:GHT ---date:2026-05-29 for【QH-MES审批流设计】驳回统一回退按表注解自动调用业务接口-----------
/**
* 直接按 url+method 调用业务接口(用于驳回统一回退,动作来自 @ApprovalBizAction 注解而非节点配置)。
* 无当前处理人登录态时降级跳过。
*/
public void runByUrl(String method, String url, MesXslApprovalInstance inst) {
if (oConvertUtils.isEmpty(url) || inst == null) {
return;
}
String token = currentToken();
if (oConvertUtils.isEmpty(token)) {
log.warn("[审批回调] 无当前处理人登录态,跳过驳回业务回退 url={}, bizId={}", url, inst.getBizDataId());
return;
}
String httpMethod = oConvertUtils.getString(method, "POST").toUpperCase();
invoke(httpMethod, url, null, inst.getBizDataId(), token);
}
//update-end---author:GHT ---date:2026-05-29 for【QH-MES审批流设计】驳回统一回退按表注解自动调用业务接口-----------
private void invoke(String method, String url, JSONObject recordedBody, String bizDataId, String token) {
String fullUrl = buildFullUrl(url);
HttpMethod httpMethod = HttpMethod.valueOf(method);
@@ -95,13 +135,15 @@ public class ApprovalActionHttpExecutor {
headers.add(CommonConstant.X_ACCESS_TOKEN, token);
headers.add(HttpHeaders.AUTHORIZATION, token);
// 统一在 url 上附带单据ID的常见参数名兼容 @RequestParam(id/ids/dataId) 形式的接口
// 如密炼PS的 /proofread、/audit、/approve 用的是 @RequestParam ids
String idValue = oConvertUtils.getString(bizDataId, "");
String sep = fullUrl.contains("?") ? "&" : "?";
fullUrl = fullUrl + sep + "id=" + idValue + "&ids=" + idValue + "&dataId=" + idValue;
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
if (!("GET".equals(method) || "DELETE".equals(method))) {
// 写操作:合并录制的 body并用单据ID覆盖 id兼容 @RequestBody 形式)
JSONObject body = recordedBody == null ? new JSONObject() : new JSONObject(recordedBody);
body.put("id", bizDataId);
bodyToSend = body;

View File

@@ -15,6 +15,8 @@ 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.action.ApprovalBizActionRegistry;
import org.jeecg.modules.xslmes.approval.action.ApprovalBizActionVo;
import org.jeecg.modules.xslmes.approval.entity.MesXslApprovalFlow;
import org.jeecg.modules.xslmes.approval.service.IMesXslApprovalFlowService;
import org.jeecg.modules.xslmes.common.MesXslTenantUtils;
@@ -47,6 +49,11 @@ public class MesXslApprovalFlowController extends JeecgController<MesXslApproval
@Autowired
private ISysBaseAPI sysBaseAPI;
//update-begin---author:GHT ---date:2026-05-29 for【QH-MES审批流设计】审批联动业务动作注册表-----
@Autowired
private ApprovalBizActionRegistry approvalBizActionRegistry;
//update-end---author:GHT ---date:2026-05-29 for【QH-MES审批流设计】审批联动业务动作注册表-----
//update-begin---author:GHT ---date:2026-05-29 for【QH-MES审批流设计】当前页字段解析-----
@Autowired
private JdbcTemplate jdbcTemplate;
@@ -258,13 +265,28 @@ public class MesXslApprovalFlowController extends JeecgController<MesXslApproval
return Result.OK(data);
}
//update-begin---author:GHT ---date:2026-05-29 for【QH-MES审批流设计】按业务表查可选回调动作-----
/**
* 查询某业务表已标注 @ApprovalBizAction 的可选回调动作,供设计器节点「回调接口」下拉选择。
*
* @param table 业务表名(审批流绑定的 bizTable)
*/
@Operation(summary = "审批流设计-业务表可选回调动作")
@RequiresPermissions("approval:flow:design")
@GetMapping(value = "/bizActions")
public Result<List<ApprovalBizActionVo>> bizActions(@RequestParam(name = "table") String table) {
return Result.OK(approvalBizActionRegistry.getByTable(table));
}
//update-end---author:GHT ---date:2026-05-29 for【QH-MES审批流设计】按业务表查可选回调动作-----
/**
* 根据前端路由反查业务表名。
* 约定jeecg 代码生成的列表组件名为 表名驼峰 + Listsys_permission.component 形如
* xslmes/mesXslFormulaSpec/MesXslFormulaSpecListurl 即路由。
* 反查url=routePath -> component -> 末段组件名去掉 List -> 驼峰转下划线得到表名。
*/
private String resolveTableByRoutePath(String routePath) {
/** 根据前端路由反查页面组件路径(sys_permission.component),用于前端按 key 取该页面按钮接口映射 */
private String resolveComponentByRoutePath(String routePath) {
if (oConvertUtils.isEmpty(routePath)) {
return null;
}
@@ -272,17 +294,20 @@ public class MesXslApprovalFlowController extends JeecgController<MesXslApproval
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<String> list = jdbcTemplate.queryForList(sql, String.class, path);
if (list.isEmpty()) {
return null;
}
component = list.get(0);
return list.isEmpty() ? null : list.get(0);
} catch (Exception e) {
log.warn("反查菜单组件失败 routePath={}", routePath, e);
return null;
}
}
private String resolveTableByRoutePath(String routePath) {
if (oConvertUtils.isEmpty(routePath)) {
return null;
}
String component = resolveComponentByRoutePath(routePath);
if (oConvertUtils.isEmpty(component)) {
return null;
}

View File

@@ -11,6 +11,7 @@ import org.jeecg.modules.xslmes.approval.service.IMesXslApprovalHandleService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.*;
import java.util.List;
import java.util.Map;
/**
@@ -71,4 +72,40 @@ public class MesXslApprovalHandleController {
LoginUser user = (LoginUser) SecurityUtils.getSubject().getPrincipal();
return approvalHandleService.reject(instanceId, reason, user);
}
//update-begin---author:GHT ---date:2026-05-29 for【QH-MES审批流设计】发起人撤销-----
@Operation(summary = "审批办理-撤销(发起人撤回)")
@PostMapping("/cancel")
public Result<String> cancel(@RequestBody Map<String, Object> 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.cancel(instanceId, reason, user);
}
//update-end---author:GHT ---date:2026-05-29 for【QH-MES审批流设计】发起人撤销-----
//update-begin---author:GHT ---date:2026-05-29 for【QH-MES审批流完善】催办接口-----
@Operation(summary = "审批办理-催办(发起人催处理人)")
@PostMapping("/urge")
public Result<String> urge(@RequestBody Map<String, Object> body) {
String instanceId = (String) body.get("instanceId");
if (oConvertUtils.isEmpty(instanceId)) {
return Result.error("缺少审批实例ID");
}
LoginUser user = (LoginUser) SecurityUtils.getSubject().getPrincipal();
return approvalHandleService.urge(instanceId, user);
}
//update-end---author:GHT ---date:2026-05-29 for【QH-MES审批流完善】催办接口-----
//update-begin---author:GHT ---date:2026-05-29 for【QH-MES审批流完善】待办列表-----
@Operation(summary = "审批办理-我的待办列表")
@GetMapping("/pendingList")
public Result<List<Map<String, Object>>> pendingList() {
LoginUser user = (LoginUser) SecurityUtils.getSubject().getPrincipal();
return Result.OK(approvalHandleService.pendingList(user));
}
//update-end---author:GHT ---date:2026-05-29 for【QH-MES审批流完善】待办列表-----
}

View File

@@ -1,5 +1,6 @@
package org.jeecg.modules.xslmes.approval.controller;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
@@ -165,6 +166,16 @@ public class MesXslApprovalLaunchController {
return Result.error("该审批流未发布,无法发起");
}
//update-begin---author:GHT ---date:2026-05-29 for【QH-MES审批流完善】防止同一单据重复发起审批-----
long active = approvalInstanceService.count(new LambdaQueryWrapper<MesXslApprovalInstance>()
.eq(MesXslApprovalInstance::getBizTable, flow.getBizTable())
.eq(MesXslApprovalInstance::getBizDataId, bizDataId)
.eq(MesXslApprovalInstance::getStatus, "0"));
if (active > 0) {
return Result.error("该单据已有审批中的流程,请勿重复发起");
}
//update-end---author:GHT ---date:2026-05-29 for【QH-MES审批流完善】防止同一单据重复发起审批-----
LoginUser loginUser = (LoginUser) SecurityUtils.getSubject().getPrincipal();
//update-begin---author:GHT ---date:2026-05-29 for【QH-MES审批流设计】发起后由引擎进入首节点(解析处理人/建进度/发可办理卡片)-----
MesXslApprovalInstance inst = buildInstance(flow, bizDataId, bizTitle, loginUser);
@@ -196,6 +207,7 @@ public class MesXslApprovalLaunchController {
LoginUser loginUser = (LoginUser) SecurityUtils.getSubject().getPrincipal();
//update-begin---author:GHT ---date:2026-05-29 for【QH-MES审批流设计】批量发起逐条进入首节点(支持按单据字段解析处理人/会签)-----
int count = 0;
int skipCount = 0;
for (Object o : (List<?>) itemsObj) {
if (!(o instanceof Map)) {
continue;
@@ -206,15 +218,29 @@ public class MesXslApprovalLaunchController {
if (oConvertUtils.isEmpty(bizDataId)) {
continue;
}
//update-begin---author:GHT ---date:2026-05-29 for【QH-MES审批流完善】批量发起防重跳过已有审批中实例的单据-----
long active = approvalInstanceService.count(new LambdaQueryWrapper<MesXslApprovalInstance>()
.eq(MesXslApprovalInstance::getBizTable, flow.getBizTable())
.eq(MesXslApprovalInstance::getBizDataId, bizDataId)
.eq(MesXslApprovalInstance::getStatus, "0"));
if (active > 0) {
skipCount++;
continue;
}
//update-end---author:GHT ---date:2026-05-29 for【QH-MES审批流完善】批量发起防重跳过已有审批中实例的单据-----
MesXslApprovalInstance inst = buildInstance(flow, bizDataId, bizTitle, loginUser);
approvalInstanceService.save(inst);
approvalHandleService.enterFirstNode(inst, loginUser);
count++;
}
if (count == 0) {
return Result.error("没有有效的单据数据");
return Result.error(skipCount > 0 ? "所选单据均已有审批中的流程,无需重复发起" : "没有有效的单据数据");
}
return Result.OK("已发起 " + count + " 条审批!");
String msg = "已发起 " + count + " 条审批!";
if (skipCount > 0) {
msg += "" + skipCount + " 条已有审批中流程,已跳过)";
}
return Result.OK(msg);
//update-end---author:GHT ---date:2026-05-29 for【QH-MES审批流设计】批量发起逐条进入首节点(支持按单据字段解析处理人/会签)-----
}

View File

@@ -43,6 +43,16 @@ public class MesXslApprovalFlow extends JeecgEntity implements Serializable {
@Schema(description = "对应功能页面前端路由(发起审批悬浮按钮仅在该页显示)")
private String routePath;
//update-begin---author:GHT ---date:2026-05-29 for【QH-MES审批流设计】驳回/撤销恢复初始状态-----
@Schema(description = "业务单据状态字段名(驳回/撤销时回写其发起时原值)")
private String statusField;
//update-end---author:GHT ---date:2026-05-29 for【QH-MES审批流设计】驳回/撤销恢复初始状态-----
//update-begin---author:GHT ---date:2026-05-29 for【QH-MES审批流完善】超时提醒配置-----
@Schema(description = "超时提醒小时数(0=不提醒)默认24")
private Integer timeoutHours;
//update-end---author:GHT ---date:2026-05-29 for【QH-MES审批流完善】超时提醒配置-----
@Schema(description = "流程设计JSON(钉钉式节点树)")
private String flowConfig;

View File

@@ -46,6 +46,14 @@ public class MesXslApprovalInstance extends JeecgEntity implements Serializable
@Schema(description = "业务单据展示标题")
private String bizTitle;
//update-begin---author:GHT ---date:2026-05-29 for【QH-MES审批流设计】驳回/撤销恢复初始状态-----
@Schema(description = "业务单据状态字段名(发起时快照)")
private String statusField;
@Schema(description = "发起审批时业务状态原值(驳回/撤销回写)")
private String originStatus;
//update-end---author:GHT ---date:2026-05-29 for【QH-MES审批流设计】驳回/撤销恢复初始状态-----
@Schema(description = "当前节点ID")
private String currentNodeId;
@@ -83,6 +91,12 @@ public class MesXslApprovalInstance extends JeecgEntity implements Serializable
@Schema(description = "备注")
private String remark;
//update-begin---author:GHT ---date:2026-05-29 for【QH-MES审批流完善】乐观锁防并发写-----
@com.baomidou.mybatisplus.annotation.Version
@Schema(description = "乐观锁版本号")
private Integer version;
//update-end---author:GHT ---date:2026-05-29 for【QH-MES审批流完善】乐观锁防并发写-----
@Schema(description = "逻辑删除0正常 1已删除")
@TableLogic
private Integer delFlag;

View File

@@ -0,0 +1,40 @@
package org.jeecg.modules.xslmes.approval.scheduler;
import lombok.extern.slf4j.Slf4j;
import org.jeecg.modules.xslmes.approval.service.IMesXslApprovalHandleService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
/**
* 审批超时提醒定时任务。
* 每小时扫描一次所有审批中的实例,对超过各流程配置 timeout_hours 仍未处理的节点
* 向当前处理人发送 IM 提醒,同一实例在同一超时周期内只提醒一次。
*
* @author GHT
* @date 2026-05-29 for【QH-MES审批流完善】超时提醒调度器
*/
//update-begin---author:GHT ---date:2026-05-29 for【QH-MES审批流完善】超时提醒调度器-----
@Component
@Slf4j
public class MesXslApprovalTimeoutScheduler {
@Autowired
private IMesXslApprovalHandleService approvalHandleService;
/**
* 每小时整点执行:扫描超时未处理的审批实例并发送提醒。
* 使用 fixedDelay 避免并发执行;应用启动 5 分钟后开始首次执行。
*/
@Scheduled(initialDelayString = "PT5M", fixedDelayString = "PT1H")
public void remindTimeout() {
log.info("开始扫描审批超时实例...");
try {
approvalHandleService.remindTimeoutInstances();
} catch (Exception e) {
log.warn("审批超时扫描异常", e);
}
log.info("审批超时实例扫描完成");
}
}
//update-end---author:GHT ---date:2026-05-29 for【QH-MES审批流完善】超时提醒调度器-----

View File

@@ -4,6 +4,7 @@ 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.List;
import java.util.Map;
/**
@@ -31,6 +32,13 @@ public interface IMesXslApprovalHandleService {
*/
Result<String> reject(String instanceId, String reason, LoginUser user);
//update-begin---author:GHT ---date:2026-05-29 for【QH-MES审批流设计】发起人撤销-----
/**
* 撤销:仅发起人本人在审批中可撤回,流程终止并将业务单据恢复到发起时状态。
*/
Result<String> cancel(String instanceId, String reason, LoginUser user);
//update-end---author:GHT ---date:2026-05-29 for【QH-MES审批流设计】发起人撤销-----
/**
* 查看单据全部字段 + 审批进度/历史,供 IM 卡片"查看详情"弹窗使用。
*/
@@ -41,4 +49,25 @@ public interface IMesXslApprovalHandleService {
* 返回 status/statusText/currentNodeId/currentNodeName/canApprove。
*/
Map<String, Object> statusInfo(String instanceId, LoginUser user);
//update-begin---author:GHT ---date:2026-05-29 for【QH-MES审批流完善】催办接口-----
/**
* 催办:发起人向当前处理人发送一次性催办提醒,同一实例每小时最多催一次。
*/
Result<String> urge(String instanceId, LoginUser user);
//update-end---author:GHT ---date:2026-05-29 for【QH-MES审批流完善】催办接口-----
//update-begin---author:GHT ---date:2026-05-29 for【QH-MES审批流完善】待办列表查询-----
/**
* 查询当前用户的待办审批列表(状态为审批中且当前处理人包含该用户)。
*/
List<Map<String, Object>> pendingList(LoginUser user);
//update-end---author:GHT ---date:2026-05-29 for【QH-MES审批流完善】待办列表查询-----
//update-begin---author:GHT ---date:2026-05-29 for【QH-MES审批流完善】超时提醒调度器调用-----
/**
* 扫描超时未处理的审批实例并向当前处理人发送提醒(由定时任务调用)。
*/
void remindTimeoutInstances();
//update-end---author:GHT ---date:2026-05-29 for【QH-MES审批流完善】超时提醒调度器调用-----
}

View File

@@ -10,6 +10,8 @@ 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.action.ApprovalBizActionRegistry;
import org.jeecg.modules.xslmes.approval.action.ApprovalBizActionVo;
import org.jeecg.modules.xslmes.approval.callback.ApprovalActionHttpExecutor;
import org.jeecg.modules.xslmes.approval.callback.ApprovalCallbackContext;
import org.jeecg.modules.xslmes.approval.callback.ApprovalCallbackDispatcher;
@@ -22,13 +24,19 @@ import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
//update-begin---author:GHT ---date:2026-05-29 for【QH-MES审批流完善】通知改为事务提交后发送,解决处理人卡片不实时置灰-----
import org.springframework.transaction.support.TransactionSynchronization;
import org.springframework.transaction.support.TransactionSynchronizationManager;
//update-end---author:GHT ---date:2026-05-29 for【QH-MES审批流完善】通知改为事务提交后发送,解决处理人卡片不实时置灰-----
import java.text.SimpleDateFormat;
import java.util.ArrayList;
import java.util.Date;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.regex.Pattern;
/**
@@ -48,6 +56,12 @@ public class MesXslApprovalHandleServiceImpl implements IMesXslApprovalHandleSer
/** 合法标识符(表名/字段名)白名单校验,防 SQL 注入 */
private static final Pattern IDENTIFIER = Pattern.compile("^[A-Za-z0-9_]+$");
//update-begin---author:GHT ---date:2026-05-29 for【QH-MES审批流完善】催办频率限制(同一实例1h内只催一次)-----
/** 记录最近一次催办时间key=instanceIdvalue=毫秒时间戳。重启后清空,可接受 */
private final Map<String, Long> urgeTimeMap = new java.util.concurrent.ConcurrentHashMap<>();
private static final long URGE_MIN_INTERVAL_MS = 60 * 60 * 1000L; // 1小时
//update-end---author:GHT ---date:2026-05-29 for【QH-MES审批流完善】催办频率限制(同一实例1h内只催一次)-----
@Autowired
private IMesXslApprovalFlowService flowService;
@@ -69,6 +83,10 @@ public class MesXslApprovalHandleServiceImpl implements IMesXslApprovalHandleSer
@Autowired
private ApprovalActionHttpExecutor actionHttpExecutor;
// 驳回统一回退:按业务表自动发现 @ApprovalBizAction(onReject) 动作,无需在每个流程节点配置
@Autowired
private ApprovalBizActionRegistry bizActionRegistry;
//update-end---author:GHT ---date:2026-05-29 for【QH-MES审批流设计】审批与业务单据联动回调-----
// ==================== 发起后进入首节点 ====================
@@ -90,7 +108,13 @@ public class MesXslApprovalHandleServiceImpl implements IMesXslApprovalHandleSer
log.error("解析流程设计失败 flowId={}", inst.getFlowId(), e);
return;
}
JSONObject firstApprover = findFirstApprover(root);
//update-begin---author:GHT ---date:2026-05-29 for【QH-MES审批流设计】发起时快照业务状态原值供驳回/撤销恢复-----
snapshotBizStatus(inst, flow);
//update-end---author:GHT ---date:2026-05-29 for【QH-MES审批流设计】发起时快照业务状态原值供驳回/撤销恢复-----
//update-begin---author:GHT ---date:2026-05-29 for【QH-MES审批流完善】发起时读取业务记录用于条件分支评估-----
Map<String, String> bizRecord = readBizRecord(inst.getBizTable(), inst.getBizDataId());
JSONObject firstApprover = findFirstApprover(root, bizRecord);
//update-end---author:GHT ---date:2026-05-29 for【QH-MES审批流完善】发起时读取业务记录用于条件分支评估-----
if (firstApprover == null) {
// 无审批节点 -> 自动通过
inst.setStatus("1");
@@ -168,6 +192,9 @@ public class MesXslApprovalHandleServiceImpl implements IMesXslApprovalHandleSer
inst.setNodeProgress(progress.toJSONString());
inst.setCurrentHandlersText(pendingNames(tasks) + " 待会签");
instanceService.updateById(inst);
//update-begin---author:GHT ---date:2026-05-29 for【QH-MES审批流完善】会签模式通知其他待处理人有人已通过-----
notifyCoApproversProgress(inst, flow, tasks, user, progress.getString("nodeName"));
//update-end---author:GHT ---date:2026-05-29 for【QH-MES审批流完善】会签模式通知其他待处理人有人已通过-----
return Result.OK("已审批,等待其他会签人处理");
}
@@ -234,18 +261,90 @@ public class MesXslApprovalHandleServiceImpl implements IMesXslApprovalHandleSer
// 驳回 -> 回调业务(可回退业务状态)
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);
}
//update-begin---author:GHT ---date:2026-05-29 for【QH-MES审批流设计】驳回统一回退(按表注解自动执行,全局一处维护)-----
revertBizOnReject(inst);
//update-end---author:GHT ---date:2026-05-29 for【QH-MES审批流设计】驳回统一回退(按表注解自动执行,全局一处维护)-----
String handlerName = oConvertUtils.getString(user.getRealname(), user.getUsername());
notifyApplicant(inst, user.getUsername(),
"您发起的「" + safeTitle(inst) + "」被 " + handlerName + " 驳回。\n驳回理由" + reason);
//update-begin---author:GHT ---date:2026-05-29 for【QH-MES审批流完善】驳回通知改为事务提交后发送,发起人/会签人卡片实时置灰-----
// 通知(发起人 + 同节点其他会签人)延迟到事务提交后发送:
// 否则对方收到WS时驳回事务尚未提交查 getApprovalStatus 仍是「审批中」,卡片按钮不会实时置灰。
final String nodeNameSnapshot = progress.getString("nodeName");
final String handlerName = oConvertUtils.getString(user.getRealname(), user.getUsername());
runAfterCommit(() -> {
notifyCoHandlersOnReject(inst, tasks, user, reason, nodeNameSnapshot);
notifyApplicant(inst, user.getUsername(),
"您发起的「" + safeTitle(inst) + "」被 " + handlerName + " 驳回。\n驳回理由" + reason);
});
//update-end---author:GHT ---date:2026-05-29 for【QH-MES审批流完善】驳回通知改为事务提交后发送,发起人/会签人卡片实时置灰-----
return Result.OK("已驳回");
}
// ==================== 撤销(发起人撤回) ====================
//update-begin---author:GHT ---date:2026-05-29 for【QH-MES审批流设计】发起人撤销并恢复业务单据到发起时状态-----
@Override
@Transactional(rollbackFor = Exception.class)
public Result<String> cancel(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 (user == null || !user.getUsername().equals(inst.getApplyUser())) {
return Result.error("只有发起人本人可以撤销该审批");
}
//update-begin---author:GHT ---date:2026-05-29 for【QH-MES审批流完善】撤销仅允许单据仍处于发起前(最初)状态时进行-----
// 撤销 = 撤回整个审批流,只能在单据还是发起前最初状态时执行;
// 一旦下游节点已确认并推进了业务状态(current != 发起时快照originStatus),则不允许撤销。
String statusField = inst.getStatusField();
if (oConvertUtils.isNotEmpty(statusField) && inst.getOriginStatus() != null) {
String currentBizStatus = readBizFieldValue(inst.getBizTable(), inst.getBizDataId(), statusField);
if (!Objects.equals(inst.getOriginStatus(), currentBizStatus)) {
return Result.error("审批已进入下一环节并被处理,无法撤销");
}
}
//update-end---author:GHT ---date:2026-05-29 for【QH-MES审批流完善】撤销仅允许单据仍处于发起前(最初)状态时进行-----
String comment = oConvertUtils.getString(reason, "发起人撤销");
appendHistory(inst, inst.getCurrentNodeId(), inst.getCurrentNodeName(), user, "cancel", comment);
//update-begin---author:GHT ---date:2026-05-29 for【QH-MES审批流完善】撤销通知改为事务提交后发送,处理人卡片实时置灰-----
// 先快照当前处理人(下面会清空)与通知所需信息,通知延迟到事务提交后发送:
// 否则处理人收到WS时撤销事务尚未提交查 getApprovalStatus 仍是「审批中」,卡片按钮不会实时置灰。
final String handlersSnapshot = inst.getCurrentHandlers();
final String applicantName = oConvertUtils.getString(inst.getApplyUserName(), inst.getApplyUser());
final String bizTitleSnapshot = safeTitle(inst);
final String fromUserId = user.getId();
final Integer tenantId = inst.getTenantId();
//update-end---author:GHT ---date:2026-05-29 for【QH-MES审批流完善】撤销通知改为事务提交后发送,处理人卡片实时置灰-----
inst.setStatus("3");
inst.setCurrentHandlers(null);
inst.setCurrentHandlersText("已撤销");
instanceService.updateById(inst);
//update-begin---author:GHT ---date:2026-05-29 for【QH-MES审批流完善】撤销改为恢复发起前快照,不再走业务「拒绝/驳回」接口-----
// 撤销时单据仍处于发起前最初状态,只需把状态字段恢复到发起时快照(幂等)
// 不能调用业务 onReject「拒绝」接口——该接口语义是从更高环节往回退单据已是最初状态时会报「无需拒绝」导致撤销失败。
restoreBizStatus(inst);
//update-end---author:GHT ---date:2026-05-29 for【QH-MES审批流完善】撤销改为恢复发起前快照,不再走业务「拒绝/驳回」接口-----
//update-begin---author:GHT ---date:2026-05-29 for【QH-MES审批流完善】撤销通知改为事务提交后发送,处理人卡片实时置灰-----
runAfterCommit(() -> {
if (oConvertUtils.isNotEmpty(handlersSnapshot)) {
for (String handler : handlersSnapshot.split(",")) {
sendOne(fromUserId, handler, tenantId,
"您待办的「" + bizTitleSnapshot + "」已被发起人 " + applicantName + " 撤销,无需处理。", "text");
}
}
});
//update-end---author:GHT ---date:2026-05-29 for【QH-MES审批流完善】撤销通知改为事务提交后发送,处理人卡片实时置灰-----
return Result.OK("已撤销");
}
//update-end---author:GHT ---date:2026-05-29 for【QH-MES审批流设计】发起人撤销并恢复业务单据到发起时状态-----
// ==================== 查看详情 ====================
@Override
@@ -326,7 +425,9 @@ public class MesXslApprovalHandleServiceImpl implements IMesXslApprovalHandleSer
instanceService.updateById(inst);
// 流程终止(等同驳回) -> 回调业务(可回退业务状态)
callbackDispatcher.fireRejected(buildContext(inst, nodeId, nodeName, null, "审批人为空,流程终止"));
actionHttpExecutor.run(node, "onReject", inst);
//update-begin---author:GHT ---date:2026-05-29 for【QH-MES审批流设计】流程终止驳回统一回退(按表注解自动执行)-----
revertBizOnReject(inst);
//update-end---author:GHT ---date:2026-05-29 for【QH-MES审批流设计】流程终止驳回统一回退(按表注解自动执行)-----
notifyApplicant(inst, null, "您发起的「" + safeTitle(inst) + "」在「" + nodeName + "」节点无处理人,流程已终止。");
return;
}
@@ -354,6 +455,9 @@ public class MesXslApprovalHandleServiceImpl implements IMesXslApprovalHandleSer
progress.put("nodeName", nodeName);
progress.put("mode", mode);
progress.put("tasks", tasks);
//update-begin---author:GHT ---date:2026-05-29 for【QH-MES审批流完善】记录进入节点时间供超时提醒计算-----
progress.put("enterTime", now());
//update-end---author:GHT ---date:2026-05-29 for【QH-MES审批流完善】记录进入节点时间供超时提醒计算-----
// 活动处理人:依次审批仅首个,其余全部
List<String> activeUsers = new ArrayList<>();
@@ -378,10 +482,11 @@ public class MesXslApprovalHandleServiceImpl implements IMesXslApprovalHandleSer
sendApprovalCard(inst, flow, nodeName, activeUsers);
}
/** 当前节点完成后,查找下一审批节点并推进;无则置为通过并通知发起人。 */
//update-begin---author:GHT ---date:2026-05-29 for【QH-MES审批流完善】条件分支路由替换原始线性flatten-----
/** 当前节点完成后,按条件分支路由查找下一审批节点并推进;无则置为通过并通知发起人。 */
private void advanceAfter(MesXslApprovalInstance inst, MesXslApprovalFlow flow, JSONObject root, String currentNodeId, LoginUser actingUser) {
List<JSONObject> chain = new ArrayList<>();
flatten(root, chain);
Map<String, String> bizRecord = readBizRecord(inst.getBizTable(), inst.getBizDataId());
List<JSONObject> chain = buildExecSequence(root, bizRecord);
int idx = -1;
for (int i = 0; i < chain.size(); i++) {
if (currentNodeId != null && currentNodeId.equals(chain.get(i).getString("id"))) {
@@ -401,6 +506,10 @@ public class MesXslApprovalHandleServiceImpl implements IMesXslApprovalHandleSer
}
}
if (nextApprover != null) {
//update-begin---author:GHT ---date:2026-05-29 for【QH-MES审批流完善】节点流转时通知发起人进度-----
notifyApplicant(inst, actingUser == null ? null : actingUser.getUsername(),
"您发起的「" + safeTitle(inst) + "」已进入下一审批节点「" + nextApprover.getString("name") + "」,请关注进展。");
//update-end---author:GHT ---date:2026-05-29 for【QH-MES审批流完善】节点流转时通知发起人进度-----
enterNode(inst, flow, root, nextApprover);
} else {
inst.setStatus("1");
@@ -414,6 +523,7 @@ public class MesXslApprovalHandleServiceImpl implements IMesXslApprovalHandleSer
notifyApplicant(inst, actor, "您发起的「" + safeTitle(inst) + "」审批已全部通过。");
}
}
//update-end---author:GHT ---date:2026-05-29 for【QH-MES审批流完善】条件分支路由替换原始线性flatten-----
// ==================== 处理人解析 ====================
@@ -573,7 +683,9 @@ public class MesXslApprovalHandleServiceImpl implements IMesXslApprovalHandleSer
String content;
String msgType;
if (oConvertUtils.isNotEmpty(routePath)) {
content = buildCardJson(inst, null, false, routePath);
//update-begin---author:GHT ---date:2026-05-29 for【QH-MES审批流设计】抄送卡片不带办理按钮,抄送人无需审核-----
content = buildCardJson(inst, null, false, routePath, false);
//update-end---author:GHT ---date:2026-05-29 for【QH-MES审批流设计】抄送卡片不带办理按钮,抄送人无需审核-----
msgType = "biz_record";
} else {
content = String.format("【抄送通知】%s 发起的「%s」抄送给您。",
@@ -620,11 +732,22 @@ public class MesXslApprovalHandleServiceImpl implements IMesXslApprovalHandleSer
/** 构建 biz_record 卡片 JSON含 instanceId / actionLabel / canApprove与前端 ImBizRecordPayload 对齐 v=2 */
private String buildCardJson(MesXslApprovalInstance inst, String actionLabel, boolean canApprove, String routePath) {
//update-begin---author:GHT ---date:2026-05-29 for【QH-MES审批流设计】区分审批卡片与抄送通知卡片-----
return buildCardJson(inst, actionLabel, canApprove, routePath, true);
}
/**
* @param approvalCard true=审批卡片(带 instanceId/办理按钮)false=抄送通知卡片(仅展示+定位链接,无办理按钮)
*/
private String buildCardJson(MesXslApprovalInstance inst, String actionLabel, boolean canApprove, String routePath, boolean approvalCard) {
//update-end---author:GHT ---date:2026-05-29 for【QH-MES审批流设计】区分审批卡片与抄送通知卡片-----
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(), "审批"));
//update-begin---author:GHT ---date:2026-05-29 for【QH-MES审批流设计】抄送卡片标注「抄送知会」-----
addField(fields, approvalCard ? "当前节点" : "知会", oConvertUtils.getString(inst.getCurrentNodeName(), approvalCard ? "审批" : "抄送"));
//update-end---author:GHT ---date:2026-05-29 for【QH-MES审批流设计】抄送卡片标注「抄送知会」-----
addField(fields, "状态", statusText(inst.getStatus()));
JSONObject item = new JSONObject();
@@ -641,14 +764,18 @@ public class MesXslApprovalHandleServiceImpl implements IMesXslApprovalHandleSer
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);
//update-begin---author:GHT ---date:2026-05-29 for【QH-MES审批流设计】仅审批卡片带办理上下文,抄送卡片不可办理-----
if (approvalCard) {
// 审批扩展字段
item.put("instanceId", inst.getId());
item.put("canApprove", canApprove);
// 该卡片对应的节点ID(用于前端实时校验是否仍为当前节点,旧节点卡片自动置灰)
item.put("nodeId", inst.getCurrentNodeId());
if (oConvertUtils.isNotEmpty(actionLabel)) {
item.put("actionLabel", actionLabel);
}
}
//update-end---author:GHT ---date:2026-05-29 for【QH-MES审批流设计】仅审批卡片带办理上下文,抄送卡片不可办理-----
JSONArray items = new JSONArray();
items.add(item);
@@ -668,6 +795,111 @@ public class MesXslApprovalHandleServiceImpl implements IMesXslApprovalHandleSer
fields.add(f);
}
// ==================== 业务状态快照/恢复 ====================
//update-begin---author:GHT ---date:2026-05-29 for【QH-MES审批流设计】驳回/撤销恢复初始状态-----
/**
* 发起审批时快照业务单据状态字段原值,写入实例。
* 状态字段名来源:审批流配置 status_field 优先,未配置时自动探测单据表是否存在 status 列。
*/
private void snapshotBizStatus(MesXslApprovalInstance inst, MesXslApprovalFlow flow) {
try {
String statusField = flow == null ? null : flow.getStatusField();
if (oConvertUtils.isEmpty(statusField) && hasColumn(inst.getBizTable(), "status")) {
statusField = "status";
}
if (oConvertUtils.isEmpty(statusField)) {
return;
}
inst.setStatusField(statusField);
inst.setOriginStatus(readBizFieldValue(inst.getBizTable(), inst.getBizDataId(), statusField));
} catch (Exception e) {
log.warn("快照业务状态失败 table={}, id={}", inst.getBizTable(), inst.getBizDataId(), e);
}
}
/**
* 驳回/撤销/终止统一回退入口(全局一处维护,所有流程通用):
* 按业务表自动发现标注了 {@code @ApprovalBizAction(phase=onReject)} 的业务接口并调用,
* 由业务接口全权负责回退(本单据状态 + 关联单据级联),无需在每个流程节点手动配置「驳回时执行」。
* 未标注 onReject 业务动作的单据,退化为通用「恢复到发起时状态」。
*/
private void revertBizOnReject(MesXslApprovalInstance inst) {
//update-begin---author:GHT ---date:2026-05-29 for【QH-MES审批流完善】单据仍在发起前状态时驳回:跳过业务「拒绝」接口,避免「无需拒绝」报错-----
// 若单据当前状态仍等于发起前快照(说明尚未被任何节点审批推进,也没有级联变更)
// 则无需调用业务 onReject「拒绝」接口(其语义是从更高环节往回退,单据已是最初状态会报「无需拒绝」)
// 直接恢复快照(幂等)即可。常见于在第一个审批节点(如校对)就驳回的场景。
if (isBizAtOriginStatus(inst)) {
restoreBizStatus(inst);
return;
}
//update-end---author:GHT ---date:2026-05-29 for【QH-MES审批流完善】单据仍在发起前状态时驳回:跳过业务「拒绝」接口,避免「无需拒绝」报错-----
List<ApprovalBizActionVo> rejectActions = bizActionRegistry.getByTableAndPhase(inst.getBizTable(), "onReject");
if (rejectActions != null && !rejectActions.isEmpty()) {
for (ApprovalBizActionVo action : rejectActions) {
actionHttpExecutor.runByUrl(action.getMethod(), action.getUrl(), inst);
}
} else {
restoreBizStatus(inst);
}
}
//update-begin---author:GHT ---date:2026-05-29 for【QH-MES审批流完善】判断业务单据是否仍处于发起前(最初)状态-----
/**
* 判断业务单据当前状态字段是否仍等于发起审批时的快照原值。
* true 表示单据尚未被任何节点推进(仍是最初状态);无快照字段时返回 false(交由正常业务回退处理)。
*/
private boolean isBizAtOriginStatus(MesXslApprovalInstance inst) {
String statusField = inst.getStatusField();
if (oConvertUtils.isEmpty(statusField) || inst.getOriginStatus() == null) {
return false;
}
String current = readBizFieldValue(inst.getBizTable(), inst.getBizDataId(), statusField);
return Objects.equals(inst.getOriginStatus(), current);
}
//update-end---author:GHT ---date:2026-05-29 for【QH-MES审批流完善】判断业务单据是否仍处于发起前(最初)状态-----
/**
* 驳回/撤销/终止时,将业务单据状态字段直接回写为发起时原值。
* 直接 UPDATE不触发业务级联同步确保单据回到“可重新提交”的初始态。
*/
private void restoreBizStatus(MesXslApprovalInstance inst) {
String table = inst.getBizTable();
String field = inst.getStatusField();
String bizDataId = inst.getBizDataId();
String origin = inst.getOriginStatus();
if (oConvertUtils.isEmpty(table) || oConvertUtils.isEmpty(field)
|| oConvertUtils.isEmpty(bizDataId) || origin == null) {
return;
}
if (!IDENTIFIER.matcher(table).matches() || !IDENTIFIER.matcher(field).matches()) {
return;
}
try {
jdbcTemplate.update("UPDATE " + table + " SET " + field + " = ? WHERE id = ?", origin, bizDataId);
log.info("审批结束恢复业务状态 table={}, id={}, {}={}", table, bizDataId, field, origin);
} catch (Exception e) {
log.warn("恢复业务状态失败 table={}, id={}, field={}", table, bizDataId, field, e);
}
}
/** 判断业务表是否存在指定列 */
private boolean hasColumn(String table, String column) {
if (oConvertUtils.isEmpty(table) || !IDENTIFIER.matcher(table).matches()) {
return false;
}
try {
Integer cnt = jdbcTemplate.queryForObject(
"SELECT COUNT(1) FROM information_schema.columns "
+ "WHERE table_schema = (SELECT DATABASE()) AND table_name = ? AND column_name = ?",
Integer.class, table, column);
return cnt != null && cnt > 0;
} catch (Exception e) {
return false;
}
}
//update-end---author:GHT ---date:2026-05-29 for【QH-MES审批流设计】驳回/撤销恢复初始状态-----
// ==================== 业务表读取 ====================
/** 读取单据指定字段值 */
@@ -768,29 +1000,146 @@ public class MesXslApprovalHandleServiceImpl implements IMesXslApprovalHandleSer
}
}
// ==================== 流程树遍历 ====================
// ==================== 流程树遍历(含条件分支评估) ====================
/** 沿流程树查找第一个审批节点(遇条件分支取首个分支后续) */
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"));
//update-begin---author:GHT ---date:2026-05-29 for【QH-MES审批流完善】条件分支评估引擎-----
/**
* 构建考虑条件分支后的执行序列approver/cc 节点按执行顺序排列)。
* 遇到 branch 节点时评估条件,选择唯一匹配的分支路径;无匹配则走最后一个分支(默认分支)。
*/
private List<JSONObject> buildExecSequence(JSONObject root, Map<String, String> bizRecord) {
List<JSONObject> seq = new ArrayList<>();
buildExecSeq(root, seq, bizRecord);
return seq;
}
/** 按节点ID查找节点含条件分支与子节点 */
private void buildExecSeq(JSONObject node, List<JSONObject> out, Map<String, String> bizRecord) {
if (node == null) {
return;
}
String type = node.getString("type");
if ("approver".equals(type) || "cc".equals(type)) {
out.add(node);
}
if ("branch".equals(type)) {
// 条件分支容器:评估后选唯一路径,之后继续 childNode
JSONObject chosen = selectBranch(node, bizRecord);
if (chosen != null) {
buildExecSeq(chosen.getJSONObject("childNode"), out, bizRecord);
}
buildExecSeq(node.getJSONObject("childNode"), out, bizRecord);
return;
}
buildExecSeq(node.getJSONObject("childNode"), out, bizRecord);
}
/**
* 从 branch 节点的 conditionNodes 中选出第一个满足条件的分支;
* 无匹配时返回最后一个分支(视为"否则"默认分支)。
*/
private JSONObject selectBranch(JSONObject branchNode, Map<String, String> bizRecord) {
JSONArray condNodes = branchNode.getJSONArray("conditionNodes");
if (condNodes == null || condNodes.isEmpty()) {
return null;
}
JSONObject defaultBranch = condNodes.getJSONObject(condNodes.size() - 1); // 最后一个作为默认
for (int i = 0; i < condNodes.size() - 1; i++) { // 跳过最后一个(默认)
JSONObject branch = condNodes.getJSONObject(i);
JSONObject props = branch.getJSONObject("props");
if (props == null) {
continue;
}
JSONArray conditions = props.getJSONArray("conditions");
if (conditions == null || conditions.isEmpty()) {
continue;
}
boolean isAnd = "and".equalsIgnoreCase(oConvertUtils.getString(props.getString("conditionType"), "and"));
if (evaluateConditions(conditions, bizRecord, isAnd)) {
return branch;
}
}
return defaultBranch;
}
private boolean evaluateConditions(JSONArray conditions, Map<String, String> bizRecord, boolean isAnd) {
if (conditions == null || conditions.isEmpty()) {
return true;
}
for (int i = 0; i < conditions.size(); i++) {
JSONObject cond = conditions.getJSONObject(i);
String field = cond.getString("field");
String operator = oConvertUtils.getString(cond.getString("operator"), "eq");
String expected = cond.getString("value");
String actual = bizRecord == null ? null : bizRecord.get(field);
boolean match = evaluateCondition(actual, operator, expected);
if (isAnd && !match) {
return false;
}
if (!isAnd && match) {
return true;
}
}
return isAnd; // and全部通过or没有一个通过
}
private boolean evaluateCondition(String actual, String operator, String expected) {
switch (operator) {
case "empty": return actual == null || actual.trim().isEmpty();
case "notEmpty": return actual != null && !actual.trim().isEmpty();
case "eq": return Objects.equals(actual, expected);
case "ne": return !Objects.equals(actual, expected);
case "contains": return actual != null && expected != null && actual.contains(expected);
case "gt": case "gte": case "lt": case "lte":
try {
double a = Double.parseDouble(actual == null ? "" : actual.trim());
double e = Double.parseDouble(expected == null ? "" : expected.trim());
if ("gt".equals(operator)) return a > e;
if ("gte".equals(operator)) return a >= e;
if ("lt".equals(operator)) return a < e;
return a <= e;
} catch (NumberFormatException ex) {
return false;
}
default: return false;
}
}
/** 读取业务单据全部字段为 Map用于条件分支评估 */
private Map<String, String> readBizRecord(String table, String bizDataId) {
Map<String, String> result = new HashMap<>();
if (oConvertUtils.isEmpty(table) || oConvertUtils.isEmpty(bizDataId) || !IDENTIFIER.matcher(table).matches()) {
return result;
}
try {
List<Map<String, Object>> rows = jdbcTemplate.queryForList(
"SELECT * FROM " + table + " WHERE id = ? LIMIT 1", bizDataId);
if (rows.isEmpty()) {
return result;
}
for (Map.Entry<String, Object> e : rows.get(0).entrySet()) {
result.put(e.getKey(), e.getValue() == null ? null : String.valueOf(e.getValue()));
}
} catch (Exception e) {
log.warn("读取业务单据失败(条件分支) table={}, id={}", table, bizDataId, e);
}
return result;
}
/** 沿执行路径查找第一个审批节点(考虑条件分支) */
private JSONObject findFirstApprover(JSONObject root, Map<String, String> bizRecord) {
List<JSONObject> seq = buildExecSequence(root, bizRecord);
for (JSONObject n : seq) {
if ("approver".equals(n.getString("type"))) {
return n;
}
}
return null;
}
//update-end---author:GHT ---date:2026-05-29 for【QH-MES审批流完善】条件分支评估引擎-----
/** 按节点ID查找节点含条件分支与子节点全树搜索 */
private JSONObject findNodeById(JSONObject node, String nodeId) {
if (node == null || oConvertUtils.isEmpty(nodeId)) {
return null;
@@ -810,24 +1159,6 @@ public class MesXslApprovalHandleServiceImpl implements IMesXslApprovalHandleSer
return findNodeById(node.getJSONObject("childNode"), nodeId);
}
/** 前序遍历收集审批人/抄送节点(条件分支按顺序展开) */
private void flatten(JSONObject node, List<JSONObject> 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) {
@@ -944,7 +1275,10 @@ public class MesXslApprovalHandleServiceImpl implements IMesXslApprovalHandleSer
JSONObject v = new JSONObject();
v.put("nodeName", h.getString("nodeName"));
v.put("name", h.getString("name"));
v.put("actionText", "approve".equals(h.getString("action")) ? "通过" : "驳回");
//update-begin---author:GHT ---date:2026-05-29 for【QH-MES审批流设计】历史动作文案支持撤销-----
String act = h.getString("action");
v.put("actionText", "approve".equals(act) ? "通过" : ("cancel".equals(act) ? "撤销" : "驳回"));
//update-end---author:GHT ---date:2026-05-29 for【QH-MES审批流设计】历史动作文案支持撤销-----
v.put("comment", h.getString("comment"));
v.put("time", h.getString("time"));
out.add(v);
@@ -994,6 +1328,33 @@ public class MesXslApprovalHandleServiceImpl implements IMesXslApprovalHandleSer
}
}
//update-begin---author:GHT ---date:2026-05-29 for【QH-MES审批流完善】事务提交后执行(用于IM通知,避免WS先于事务提交导致前端查到旧状态)-----
/**
* 在当前事务提交成功后执行任务如发送IM/WS通知
* 若当前无活动事务,则立即执行。
* 用于解决「通知WS先于业务事务提交到达前端查到旧状态」的时序问题。
*/
private void runAfterCommit(Runnable task) {
if (task == null) {
return;
}
if (TransactionSynchronizationManager.isSynchronizationActive()) {
TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
@Override
public void afterCommit() {
try {
task.run();
} catch (Exception e) {
log.warn("事务提交后通知任务执行失败", e);
}
}
});
} else {
task.run();
}
}
//update-end---author:GHT ---date:2026-05-29 for【QH-MES审批流完善】事务提交后执行(用于IM通知,避免WS先于事务提交导致前端查到旧状态)-----
private List<String> singletonList(String s) {
List<String> l = new ArrayList<>();
l.add(s);
@@ -1020,4 +1381,194 @@ public class MesXslApprovalHandleServiceImpl implements IMesXslApprovalHandleSer
private String now() {
return new SimpleDateFormat("yyyy-MM-dd HH:mm:ss").format(new Date());
}
// ==================== 催办 ====================
//update-begin---author:GHT ---date:2026-05-29 for【QH-MES审批流完善】催办发起人向当前处理人发催办提醒-----
@Override
public Result<String> urge(String instanceId, LoginUser user) {
MesXslApprovalInstance inst = instanceService.getById(instanceId);
if (inst == null) {
return Result.error("审批实例不存在");
}
if (!"0".equals(inst.getStatus())) {
return Result.error("该审批已结束,无需催办");
}
if (user == null || !user.getUsername().equals(inst.getApplyUser())) {
return Result.error("只有发起人才可以催办");
}
Long lastUrge = urgeTimeMap.get(instanceId);
if (lastUrge != null && System.currentTimeMillis() - lastUrge < URGE_MIN_INTERVAL_MS) {
return Result.error("催办过于频繁请1小时后再试");
}
if (oConvertUtils.isEmpty(inst.getCurrentHandlers())) {
return Result.error("当前无待处理人");
}
String applicantName = oConvertUtils.getString(inst.getApplyUserName(), inst.getApplyUser());
for (String handler : inst.getCurrentHandlers().split(",")) {
String uname = handler == null ? "" : handler.trim();
if (oConvertUtils.isEmpty(uname)) {
continue;
}
sendOne(user.getId(), uname, inst.getTenantId(),
"【催办提醒】" + applicantName + " 催促您处理「" + safeTitle(inst) + "」,请尽快审批。", "text");
}
urgeTimeMap.put(instanceId, System.currentTimeMillis());
return Result.OK("催办已发送");
}
//update-end---author:GHT ---date:2026-05-29 for【QH-MES审批流完善】催办发起人向当前处理人发催办提醒-----
// ==================== 待办列表 ====================
//update-begin---author:GHT ---date:2026-05-29 for【QH-MES审批流完善】待办列表查询当前用户的待处理审批实例-----
@Override
public List<Map<String, Object>> pendingList(LoginUser user) {
if (user == null) {
return new ArrayList<>();
}
String sql = "SELECT id, flow_name, biz_table_name, biz_title, current_node_name, "
+ "apply_user_name, apply_time, current_handlers_text "
+ "FROM mes_xsl_approval_instance "
+ "WHERE status = '0' AND del_flag = 0 "
+ "AND FIND_IN_SET(?, current_handlers) "
+ "ORDER BY apply_time DESC LIMIT 200";
try {
return jdbcTemplate.queryForList(sql, user.getUsername());
} catch (Exception e) {
log.warn("查询待办列表失败 user={}", user.getUsername(), e);
return new ArrayList<>();
}
}
//update-end---author:GHT ---date:2026-05-29 for【QH-MES审批流完善】待办列表查询当前用户的待处理审批实例-----
// ==================== 超时提醒(调度器调用) ====================
//update-begin---author:GHT ---date:2026-05-29 for【QH-MES审批流完善】超时提醒扫描超时实例并通知处理人-----
@Override
public void remindTimeoutInstances() {
// 查询所有审批中的实例,联查审批流的超时配置
String sql = "SELECT i.id, i.flow_id, i.biz_title, i.current_handlers, i.node_progress, "
+ "i.tenant_id, i.apply_user, i.apply_user_name, i.flow_name, "
+ "COALESCE(f.timeout_hours, 24) AS timeout_hours "
+ "FROM mes_xsl_approval_instance i "
+ "LEFT JOIN mes_xsl_approval_flow f ON f.id = i.flow_id "
+ "WHERE i.status = '0' AND i.del_flag = 0 AND COALESCE(f.timeout_hours, 24) > 0";
List<Map<String, Object>> rows;
try {
rows = jdbcTemplate.queryForList(sql);
} catch (Exception e) {
log.warn("超时提醒扫描失败", e);
return;
}
for (Map<String, Object> row : rows) {
try {
processTimeoutRow(row);
} catch (Exception e) {
log.warn("处理超时实例失败 id={}", row.get("id"), e);
}
}
}
private void processTimeoutRow(Map<String, Object> row) {
String nodeProgress = (String) row.get("node_progress");
if (oConvertUtils.isEmpty(nodeProgress)) {
return;
}
JSONObject progress = safeParse(nodeProgress);
if (progress == null) {
return;
}
String enterTimeStr = progress.getString("enterTime");
if (oConvertUtils.isEmpty(enterTimeStr)) {
return;
}
Date enterTime;
try {
enterTime = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss").parse(enterTimeStr);
} catch (Exception e) {
return;
}
int timeoutHours = row.get("timeout_hours") == null ? 24 : ((Number) row.get("timeout_hours")).intValue();
long elapsedHours = (System.currentTimeMillis() - enterTime.getTime()) / (1000 * 60 * 60);
if (elapsedHours < timeoutHours) {
return;
}
// 已超时:通知当前处理人
String currentHandlers = (String) row.get("current_handlers");
if (oConvertUtils.isEmpty(currentHandlers)) {
return;
}
String instanceId = (String) row.get("id");
// 避免重复通知:每个超时周期内只通知一次
String reminderKey = instanceId + "_timeout";
Long lastRemind = urgeTimeMap.get(reminderKey);
if (lastRemind != null && System.currentTimeMillis() - lastRemind < timeoutHours * 3600_000L) {
return;
}
String bizTitle = oConvertUtils.getString((String) row.get("biz_title"), instanceId);
String flowName = oConvertUtils.getString((String) row.get("flow_name"), "");
String applyUserName = oConvertUtils.getString((String) row.get("apply_user_name"), "");
Integer tenantId = row.get("tenant_id") == null ? null : ((Number) row.get("tenant_id")).intValue();
String msg = String.format("【超时提醒】您有一条审批待处理已超过%d小时请尽快处理\n审批流%s\n单据%s\n发起人%s",
elapsedHours, flowName, bizTitle, applyUserName);
String applyUser = (String) row.get("apply_user");
SysUser fromUser = getUserSafely(applyUser);
String fromId = fromUser == null ? null : fromUser.getId();
for (String handler : currentHandlers.split(",")) {
String uname = handler == null ? "" : handler.trim();
if (oConvertUtils.isEmpty(uname)) {
continue;
}
sendOne(fromId, uname, tenantId, msg, "text");
}
urgeTimeMap.put(reminderKey, System.currentTimeMillis());
log.info("审批超时提醒已发送 instanceId={}, elapsedHours={}", instanceId, elapsedHours);
}
//update-end---author:GHT ---date:2026-05-29 for【QH-MES审批流完善】超时提醒扫描超时实例并通知处理人-----
// ==================== 辅助通知方法 ====================
//update-begin---author:GHT ---date:2026-05-29 for【QH-MES审批流完善】全链路通知补全-----
/** 会签模式:某人通过后通知其他仍待处理的处理人 */
private void notifyCoApproversProgress(MesXslApprovalInstance inst, MesXslApprovalFlow flow,
JSONArray tasks, LoginUser actingUser, String nodeName) {
String actorName = actingUser == null ? "某人"
: oConvertUtils.getString(actingUser.getRealname(), actingUser.getUsername());
String msg = "【会签进度】" + actorName + " 已通过「" + safeTitle(inst) + "」的「"
+ oConvertUtils.getString(nodeName, "审批") + "」节点,等待您审批。";
SysUser from = actingUser == null ? null : getUserSafely(actingUser.getUsername());
String fromId = from == null ? null : from.getId();
for (int i = 0; i < tasks.size(); i++) {
JSONObject t = tasks.getJSONObject(i);
if ("pending".equals(t.getString("status"))
&& (actingUser == null || !actingUser.getUsername().equals(t.getString("username")))) {
sendOne(fromId, t.getString("username"), inst.getTenantId(), msg, "text");
}
}
}
/** 驳回时通知同节点其他待处理人(无需处理,流程已结束) */
private void notifyCoHandlersOnReject(MesXslApprovalInstance inst,
JSONArray tasks, LoginUser actingUser, String reason, String nodeName) {
if (tasks == null) {
return;
}
String actorName = actingUser == null ? "某人"
: oConvertUtils.getString(actingUser.getRealname(), actingUser.getUsername());
String msg = "【审批驳回】" + actorName + " 驳回了「" + safeTitle(inst) + "」,流程已终止,无需您继续处理。\n驳回理由" + reason;
SysUser from = actingUser == null ? null : getUserSafely(actingUser.getUsername());
String fromId = from == null ? null : from.getId();
for (int i = 0; i < tasks.size(); i++) {
JSONObject t = tasks.getJSONObject(i);
String uname = t.getString("username");
if (oConvertUtils.isEmpty(uname)) {
continue;
}
if (actingUser != null && actingUser.getUsername().equals(uname)) {
continue;
}
sendOne(fromId, uname, inst.getTenantId(), msg, "text");
}
}
//update-end---author:GHT ---date:2026-05-29 for【QH-MES审批流完善】全链路通知补全-----
}

View File

@@ -0,0 +1,17 @@
package org.jeecg.modules.xslmes.config;
import org.springframework.context.annotation.Configuration;
import org.springframework.scheduling.annotation.EnableScheduling;
/**
* 启用 Spring @Scheduled用于审批超时提醒等定时任务。
*
* @author GHT
* @date 2026-05-29 for【QH-MES审批流完善】启用定时任务支持
*/
//update-begin---author:GHT ---date:2026-05-29 for【QH-MES审批流完善】启用定时任务支持-----
@Configuration
@EnableScheduling
public class XslMesSchedulingConfig {
}
//update-end---author:GHT ---date:2026-05-29 for【QH-MES审批流完善】启用定时任务支持-----

View File

@@ -22,6 +22,7 @@ import org.jeecg.common.system.vo.LoginUser;
import org.jeecg.common.util.oConvertUtils;
import org.jeecg.modules.system.entity.SysDepart;
import org.jeecg.modules.system.service.ISysDepartService;
import org.jeecg.modules.xslmes.approval.action.ApprovalBizAction;
import org.jeecg.modules.xslmes.entity.MesXslMixerPsCompile;
import org.jeecg.modules.xslmes.service.IMesXslMixerPsCompileService;
import org.springframework.beans.factory.annotation.Autowired;
@@ -168,6 +169,9 @@ public class MesXslMixerPsCompileController extends JeecgController<MesXslMixerP
}
//update-begin---author:jiangxh ---date:20260520 for【密炼PS编制】校对/审核/批准-----------
//update-begin---author:GHT ---date:2026-05-29 for【QH-MES审批流设计】标注为审批可选回调动作-----
@ApprovalBizAction(name = "校对", table = "mes_xsl_mixer_ps_compile", phase = {"onNodeApprove", "onApprove"}, order = 1)
//update-end---author:GHT ---date:2026-05-29 for【QH-MES审批流设计】标注为审批可选回调动作-----
@AutoLog(value = "MES密炼PS编制-校对")
@Operation(summary = "MES密炼PS编制-校对")
@RequiresPermissions("xslmes:mes_xsl_mixer_ps_compile:proofread")
@@ -176,6 +180,9 @@ public class MesXslMixerPsCompileController extends JeecgController<MesXslMixerP
return doChangeStatus(ids, "compile", "proofread", "校对");
}
//update-begin---author:GHT ---date:2026-05-29 for【QH-MES审批流设计】标注为审批可选回调动作-----
@ApprovalBizAction(name = "审核", table = "mes_xsl_mixer_ps_compile", phase = {"onNodeApprove", "onApprove"}, order = 2)
//update-end---author:GHT ---date:2026-05-29 for【QH-MES审批流设计】标注为审批可选回调动作-----
@AutoLog(value = "MES密炼PS编制-审核")
@Operation(summary = "MES密炼PS编制-审核")
@RequiresPermissions("xslmes:mes_xsl_mixer_ps_compile:audit")
@@ -184,6 +191,9 @@ public class MesXslMixerPsCompileController extends JeecgController<MesXslMixerP
return doChangeStatus(ids, "proofread", "audit", "审核");
}
//update-begin---author:GHT ---date:2026-05-29 for【QH-MES审批流设计】标注为审批可选回调动作-----
@ApprovalBizAction(name = "批准", table = "mes_xsl_mixer_ps_compile", phase = {"onNodeApprove", "onApprove"}, order = 3)
//update-end---author:GHT ---date:2026-05-29 for【QH-MES审批流设计】标注为审批可选回调动作-----
@AutoLog(value = "MES密炼PS编制-批准")
@Operation(summary = "MES密炼PS编制-批准")
@RequiresPermissions("xslmes:mes_xsl_mixer_ps_compile:approve")
@@ -201,6 +211,34 @@ public class MesXslMixerPsCompileController extends JeecgController<MesXslMixerP
return Result.OK(actionLabel + "成功!");
}
//update-begin---author:GHT ---date:2026-05-29 for【QH-MES审批流设计】密炼PS拒绝/撤回逆向回退-----------
@ApprovalBizAction(name = "拒绝", table = "mes_xsl_mixer_ps_compile", phase = {"onReject"}, order = 4)
@AutoLog(value = "MES密炼PS编制-拒绝")
@Operation(summary = "MES密炼PS编制-拒绝(一步退回编制并撤销关联操作)")
// 拒绝主要由审批流 onReject 回调触发(执行人为当前审批节点处理人)不强制要求其具备密炼PS业务权限码
// 是否允许驳回已由审批节点本身控制,故此处不加 @RequiresPermissions。
@PostMapping(value = "/reject")
public Result<String> reject(@RequestParam(name = "ids") String ids) {
String err = mesXslMixerPsCompileService.rejectBatch(ids, getOperatorName());
if (err != null) {
return Result.error(err);
}
return Result.OK("拒绝成功,已退回编制状态!");
}
@AutoLog(value = "MES密炼PS编制-撤回")
@Operation(summary = "MES密炼PS编制-撤回(退回上一环节并撤销关联操作)")
@RequiresPermissions("xslmes:mes_xsl_mixer_ps_compile:withdraw")
@PostMapping(value = "/withdraw")
public Result<String> withdraw(@RequestParam(name = "ids") String ids) {
String err = mesXslMixerPsCompileService.withdrawBatch(ids, getOperatorName());
if (err != null) {
return Result.error(err);
}
return Result.OK("撤回成功!");
}
//update-end---author:GHT ---date:2026-05-29 for【QH-MES审批流设计】密炼PS拒绝/撤回逆向回退-----------
private String getOperatorName() {
LoginUser loginUser = null;
try {

View File

@@ -21,6 +21,7 @@ import org.jeecg.common.constant.CommonConstant;
import org.jeecg.common.system.base.controller.JeecgController;
import org.jeecg.common.system.query.QueryGenerator;
import org.jeecg.common.util.oConvertUtils;
import org.jeecg.modules.xslmes.approval.action.ApprovalBizAction;
import org.jeecg.modules.mes.material.entity.MesMaterial;
import org.jeecg.modules.mes.material.service.IMesMaterialService;
import org.jeecg.modules.system.entity.SysDepart;
@@ -162,6 +163,9 @@ public class MesXslRubberQuickTestStdController
return Result.OK(mesXslRubberQuickTestStdService.selectLinesByStdId(id));
}
//update-begin---author:GHT ---date:2026-05-29 for【QH-MES审批流设计】标注为审批可选回调动作-----
@ApprovalBizAction(name = "启用/停用", table = "mes_xsl_rubber_quick_test_std", phase = {"onApprove", "onReject"})
//update-end---author:GHT ---date:2026-05-29 for【QH-MES审批流设计】标注为审批可选回调动作-----
@AutoLog(value = "MES胶料快检实验标准-启用/停用")
@Operation(summary = "MES胶料快检实验标准-启用/停用(字典 xslmes_rubber_quick_test_std_enable_status1使用中 0已停用")
@RequiresPermissions("mes:mes_xsl_rubber_quick_test_std:updateStatus")

View File

@@ -39,6 +39,16 @@ public interface IMesXslFormulaSpecService extends IService<MesXslFormulaSpec> {
void syncFromMixerPsWorkflow(MesXslMixerPsCompile ps, String mixerPsTargetStatus);
//update-end---author:cursor ---date:20260522 for【配合示方】密炼PS审批联动同步状态与审批人-----------
//update-begin---author:GHT ---date:2026-05-29 for【QH-MES审批流设计】密炼PS拒绝/撤回联动回退配合示方-----------
/**
* 密炼PS拒绝/撤回时按发行编号PS编码将关联配合示方回退到目标状态并清空高于目标状态的审批人痕迹。
*
* @param ps 已回退后的密炼PS编制单
* @param mixerPsTargetStatus 密炼PS回退后的目标状态compile / proofread / audit
*/
void revertFromMixerPsWorkflow(MesXslMixerPsCompile ps, String mixerPsTargetStatus);
//update-end---author:GHT ---date:2026-05-29 for【QH-MES审批流设计】密炼PS拒绝/撤回联动回退配合示方-----------
//update-begin---author:cursor ---date:20260522 for【XSLMES-20260522-A38】配合示方生成混炼示方预览与批量创建-----------
/**
* 根据配合示方混合段数构建生成混炼示方预览行(示方编号 + 段信息)

View File

@@ -16,4 +16,22 @@ public interface IMesXslMixerPsCompileService extends IService<MesXslMixerPsComp
*/
String changeStatusBatch(String ids, String expectedStatus, String targetStatus, String actionLabel, String operatorName);
//update-end---author:jiangxh ---date:20260520 for【密炼PS编制】批量流转状态-----------
//update-begin---author:GHT ---date:2026-05-29 for【QH-MES审批流设计】密炼PS拒绝/撤回逆向回退-----------
/**
* 批量拒绝:将单据一步退回到编制(compile),清空本单据校对/审核/批准全部痕迹,
* 并联动回退配合示方、混炼示方、原材料检验标准等关联单据。
*
* @return 失败原因null 表示成功
*/
String rejectBatch(String ids, String operatorName);
/**
* 批量撤回将单据按当前状态退回上一环节approve→audit→proofread→compile
* 仅清空被撤回那一步的痕迹,并联动回退关联单据。
*
* @return 失败原因null 表示成功
*/
String withdrawBatch(String ids, String operatorName);
//update-end---author:GHT ---date:2026-05-29 for【QH-MES审批流设计】密炼PS拒绝/撤回逆向回退-----------
}

View File

@@ -46,5 +46,15 @@ public interface IMesXslMixingSpecService extends IService<MesXslMixingSpec> {
* @param mixerPsTargetStatus 密炼PS目标状态proofread / audit / approve
*/
void syncFromMixerPsWorkflow(MesXslMixerPsCompile ps, String mixerPsTargetStatus);
//update-begin---author:GHT ---date:2026-05-29 for【QH-MES审批流设计】密炼PS拒绝/撤回联动回退混炼示方-----------
/**
* 密炼PS拒绝/撤回时按发行编号PS编码清空关联混炼示方高于目标状态的审批人痕迹。
*
* @param ps 已回退后的密炼PS编制单
* @param mixerPsTargetStatus 密炼PS回退后的目标状态compile / proofread / audit
*/
void revertFromMixerPsWorkflow(MesXslMixerPsCompile ps, String mixerPsTargetStatus);
//update-end---author:GHT ---date:2026-05-29 for【QH-MES审批流设计】密炼PS拒绝/撤回联动回退混炼示方-----------
//update-end---author:cursor ---date:20260526 for【XSLMES-20260526-A61】混炼示方密炼PS审批联动同步审批人-----------
}

View File

@@ -25,4 +25,11 @@ public interface IMesXslRubberQuickTestStdService extends IService<MesXslRubberQ
* 密炼PS原材料检验标准批准后将关联实验标准审核状态置为已批准仅写入不随PS反审核回退
*/
void markAuditApprovedByPsCompileIds(Collection<String> psCompileIds);
//update-begin---author:GHT ---date:2026-05-29 for【QH-MES审批流设计】密炼PS拒绝/撤回时关联实验标准回退为草稿-----------
/**
* 密炼PS原材料检验标准从已批准回退拒绝/撤回)时,将关联实验标准审核状态置回草稿(未批准)
*/
void markAuditDraftByPsCompileIds(Collection<String> psCompileIds);
//update-end---author:GHT ---date:2026-05-29 for【QH-MES审批流设计】密炼PS拒绝/撤回时关联实验标准回退为草稿-----------
}

View File

@@ -499,6 +499,41 @@ public class MesXslFormulaSpecServiceImpl extends ServiceImpl<MesXslFormulaSpecM
}
//update-end---author:cursor ---date:20260522 for【配合示方】密炼PS审批联动同步状态与审批人-----------
//update-begin---author:GHT ---date:2026-05-29 for【QH-MES审批流设计】密炼PS拒绝/撤回联动回退配合示方-----------
@Override
public void revertFromMixerPsWorkflow(MesXslMixerPsCompile ps, String mixerPsTargetStatus) {
if (ps == null || oConvertUtils.isEmpty(ps.getPsCode()) || oConvertUtils.isEmpty(mixerPsTargetStatus)) {
return;
}
LambdaUpdateWrapper<MesXslFormulaSpec> wrapper = new LambdaUpdateWrapper<>();
wrapper.eq(MesXslFormulaSpec::getIssueNumber, ps.getPsCode());
switch (mixerPsTargetStatus) {
case "compile":
// 回退到编制:状态回 compile清空 校对/审核/批准 三组痕迹
wrapper.set(MesXslFormulaSpec::getStatus, "compile")
.set(MesXslFormulaSpec::getProofreadBy, null).set(MesXslFormulaSpec::getProofreadTime, null)
.set(MesXslFormulaSpec::getAuditBy, null).set(MesXslFormulaSpec::getAuditTime, null)
.set(MesXslFormulaSpec::getApproveBy, null).set(MesXslFormulaSpec::getApproveTime, null);
break;
case "proofread":
// 回退到校对:状态回 submit清空 审核/批准 痕迹(保留校对)
wrapper.set(MesXslFormulaSpec::getStatus, "submit")
.set(MesXslFormulaSpec::getAuditBy, null).set(MesXslFormulaSpec::getAuditTime, null)
.set(MesXslFormulaSpec::getApproveBy, null).set(MesXslFormulaSpec::getApproveTime, null);
break;
case "audit":
// 回退到审核:状态回 review_pass清空 批准 痕迹(保留校对/审核)
wrapper.set(MesXslFormulaSpec::getStatus, "review_pass")
.set(MesXslFormulaSpec::getApproveBy, null).set(MesXslFormulaSpec::getApproveTime, null);
break;
default:
return;
}
wrapper.set(MesXslFormulaSpec::getUpdateTime, new Date());
this.update(wrapper);
}
//update-end---author:GHT ---date:2026-05-29 for【QH-MES审批流设计】密炼PS拒绝/撤回联动回退配合示方-----------
//update-begin---author:cursor ---date:20260522 for【XSLMES-20260522-A38】配合示方生成混炼示方预览与批量创建-----------
@Override
public MesXslFormulaMixingGeneratePreviewVO buildMixingGeneratePreview(String formulaSpecId) {

View File

@@ -1,8 +1,10 @@
package org.jeecg.modules.xslmes.service.impl;
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.Date;
import java.util.List;
import org.jeecg.common.util.oConvertUtils;
@@ -96,4 +98,93 @@ public class MesXslMixerPsCompileServiceImpl extends ServiceImpl<MesXslMixerPsCo
}
}
//update-end---author:jiangxh ---date:20260520 for【密炼PS编制】批量流转状态-----------
//update-begin---author:GHT ---date:2026-05-29 for【QH-MES审批流设计】密炼PS拒绝/撤回逆向回退-----------
@Override
@Transactional(rollbackFor = Exception.class)
public String rejectBatch(String ids, String operatorName) {
return doRevertBatch(ids, "拒绝", true, operatorName);
}
@Override
@Transactional(rollbackFor = Exception.class)
public String withdrawBatch(String ids, String operatorName) {
return doRevertBatch(ids, "撤回", false, operatorName);
}
/**
* 逆向回退批处理。
*
* @param toCompile true=拒绝(一步退回编制)false=撤回(退回上一环节)
*/
private String doRevertBatch(String ids, String actionLabel, boolean toCompile, String operatorName) {
if (oConvertUtils.isEmpty(ids)) {
return "请选择要" + actionLabel + "的记录";
}
for (String rawId : ids.split(",")) {
if (oConvertUtils.isEmpty(rawId)) {
continue;
}
String id = rawId.trim();
MesXslMixerPsCompile entity = getById(id);
if (entity == null) {
return "记录不存在或已删除";
}
String current = entity.getStatus() == null ? "" : entity.getStatus();
String prev = prevStatus(current);
if (prev == null) {
String psCode = oConvertUtils.isEmpty(entity.getPsCode()) ? id : entity.getPsCode();
return "PS编码[" + psCode + "]当前为编制状态,无需" + actionLabel;
}
String targetStatus = toCompile ? "compile" : prev;
applyRevert(entity, current, targetStatus);
}
return null;
}
/** 执行单条单据的逆向回退:本单据状态+痕迹回退,并联动回退关联单据 */
private void applyRevert(MesXslMixerPsCompile entity, String currentStatus, String targetStatus) {
boolean leavingApprove = "approve".equals(currentStatus);
// 本单据:状态回退 + 清空高于目标状态的痕迹(用 UpdateWrapper 显式 set null绕过 updateById 对 null 不更新)
LambdaUpdateWrapper<MesXslMixerPsCompile> wrapper = new LambdaUpdateWrapper<>();
wrapper.eq(MesXslMixerPsCompile::getId, entity.getId())
.set(MesXslMixerPsCompile::getStatus, targetStatus);
if ("compile".equals(targetStatus)) {
wrapper.set(MesXslMixerPsCompile::getProofreadBy, null).set(MesXslMixerPsCompile::getProofreadTime, null)
.set(MesXslMixerPsCompile::getAuditBy, null).set(MesXslMixerPsCompile::getAuditTime, null)
.set(MesXslMixerPsCompile::getApproveBy, null).set(MesXslMixerPsCompile::getApproveTime, null);
} else if ("proofread".equals(targetStatus)) {
wrapper.set(MesXslMixerPsCompile::getAuditBy, null).set(MesXslMixerPsCompile::getAuditTime, null)
.set(MesXslMixerPsCompile::getApproveBy, null).set(MesXslMixerPsCompile::getApproveTime, null);
} else if ("audit".equals(targetStatus)) {
wrapper.set(MesXslMixerPsCompile::getApproveBy, null).set(MesXslMixerPsCompile::getApproveTime, null);
}
update(wrapper);
// 同步实体内存值,供关联单据联动判断使用
entity.setStatus(targetStatus);
// 联动回退:配合示方(状态+审批人)、混炼示方(审批人)
mesXslFormulaSpecService.revertFromMixerPsWorkflow(entity, targetStatus);
mesXslMixingSpecService.revertFromMixerPsWorkflow(entity, targetStatus);
// 离开批准态:原材料检验标准回退为草稿(仅原料检验标准类型)
if (leavingApprove && XslMesBizConstants.PS_TYPE_RAW_INSPECT_STD.equals(entity.getPsType())) {
mesXslRubberQuickTestStdService.markAuditDraftByPsCompileIds(Collections.singletonList(entity.getId()));
}
}
/** 上一环节映射approve→audit→proofread→compilecompile 无上一环节返回 null */
private String prevStatus(String current) {
switch (current) {
case "approve":
return "audit";
case "audit":
return "proofread";
case "proofread":
return "compile";
default:
return null;
}
}
//update-end---author:GHT ---date:2026-05-29 for【QH-MES审批流设计】密炼PS拒绝/撤回逆向回退-----------
}

View File

@@ -841,6 +841,38 @@ public class MesXslMixingSpecServiceImpl extends ServiceImpl<MesXslMixingSpecMap
}
//update-end---author:cursor ---date:20260526 for【XSLMES-20260526-A61】混炼示方密炼PS审批联动同步审批人-----------
//update-begin---author:GHT ---date:2026-05-29 for【QH-MES审批流设计】密炼PS拒绝/撤回联动回退混炼示方-----------
@Override
public void revertFromMixerPsWorkflow(MesXslMixerPsCompile ps, String mixerPsTargetStatus) {
if (ps == null || oConvertUtils.isEmpty(ps.getPsCode()) || oConvertUtils.isEmpty(mixerPsTargetStatus)) {
return;
}
LambdaUpdateWrapper<MesXslMixingSpec> wrapper = new LambdaUpdateWrapper<>();
wrapper.eq(MesXslMixingSpec::getIssueNumber, ps.getPsCode());
switch (mixerPsTargetStatus) {
case "compile":
// 回退到编制:清空 校对/审核/批准 三组痕迹
wrapper.set(MesXslMixingSpec::getProofreadBy, null).set(MesXslMixingSpec::getProofreadTime, null)
.set(MesXslMixingSpec::getAuditBy, null).set(MesXslMixingSpec::getAuditTime, null)
.set(MesXslMixingSpec::getApproveBy, null).set(MesXslMixingSpec::getApproveTime, null);
break;
case "proofread":
// 回退到校对:清空 审核/批准 痕迹(保留校对)
wrapper.set(MesXslMixingSpec::getAuditBy, null).set(MesXslMixingSpec::getAuditTime, null)
.set(MesXslMixingSpec::getApproveBy, null).set(MesXslMixingSpec::getApproveTime, null);
break;
case "audit":
// 回退到审核:清空 批准 痕迹(保留校对/审核)
wrapper.set(MesXslMixingSpec::getApproveBy, null).set(MesXslMixingSpec::getApproveTime, null);
break;
default:
return;
}
wrapper.set(MesXslMixingSpec::getUpdateTime, new Date());
this.update(wrapper);
}
//update-end---author:GHT ---date:2026-05-29 for【QH-MES审批流设计】密炼PS拒绝/撤回联动回退混炼示方-----------
//update-begin---author:cursor ---date:20260527 for【配方日志查询】从入参直接构建快照避免二次查库-----------
private MesXslMixingSpecPage buildPageFromInput(
MesXslMixingSpec main,

View File

@@ -156,5 +156,24 @@ public class MesXslRubberQuickTestStdServiceImpl
}
//update-end---author:jiangxh ---date:20260525 for【MES】原材料检验标准密炼PS批准时关联实验标准置已批准-----------
//update-begin---author:GHT ---date:2026-05-29 for【QH-MES审批流设计】密炼PS拒绝/撤回时关联实验标准回退为草稿-----------
@Override
public void markAuditDraftByPsCompileIds(Collection<String> psCompileIds) {
if (CollectionUtils.isEmpty(psCompileIds)) {
return;
}
List<String> ids =
psCompileIds.stream().filter(id -> oConvertUtils.isNotEmpty(id)).map(String::trim).distinct().collect(Collectors.toList());
if (ids.isEmpty()) {
return;
}
this.lambdaUpdate()
.in(MesXslRubberQuickTestStd::getPsCompileId, ids)
.and(w -> w.eq(MesXslRubberQuickTestStd::getDelFlag, CommonConstant.DEL_FLAG_0).or().isNull(MesXslRubberQuickTestStd::getDelFlag))
.set(MesXslRubberQuickTestStd::getAuditStatus, XslMesBizConstants.RUBBER_QUICK_TEST_STD_AUDIT_DRAFT)
.update();
}
//update-end---author:GHT ---date:2026-05-29 for【QH-MES审批流设计】密炼PS拒绝/撤回时关联实验标准回退为草稿-----------
//update-end---author:jiangxh ---date:20260525 for【MES】胶料快检实验标准名称同租户唯一、主子保存-----------
}

View File

@@ -0,0 +1,14 @@
-- QH-MES审批流设计驳回/撤销恢复初始状态审批流配置状态字段名实例快照发起时业务状态原值
SET NAMES utf8mb4;
ALTER TABLE `mes_xsl_approval_flow`
ADD COLUMN `status_field` varchar(64) DEFAULT NULL COMMENT '业务单据状态字段名(驳回/撤销时回写其发起时原值)' AFTER `route_path`;
ALTER TABLE `mes_xsl_approval_instance`
ADD COLUMN `status_field` varchar(64) DEFAULT NULL COMMENT '业务单据状态字段名(发起时快照)' AFTER `biz_title`,
ADD COLUMN `origin_status` varchar(64) DEFAULT NULL COMMENT '发起审批时业务状态原值(驳回/撤销回写)' AFTER `status_field`;
-- 密炼PS编制默认以 status 字段作为可恢复状态字段
UPDATE `mes_xsl_approval_flow`
SET `status_field` = 'status'
WHERE `biz_table` = 'mes_xsl_mixer_ps_compile' AND (`status_field` IS NULL OR `status_field` = '');

View File

@@ -0,0 +1,10 @@
-- QH-MES审批流完善乐观锁防并发 超时配置 催办记录时间
SET NAMES utf8mb4;
-- 审批实例乐观锁版本号防止多人同时审批导致进度覆盖写
ALTER TABLE `mes_xsl_approval_instance`
ADD COLUMN `version` int(11) NOT NULL DEFAULT 0 COMMENT '乐观锁版本号' AFTER `remark`;
-- 审批流定义超时提醒小时数0=不提醒默认24h
ALTER TABLE `mes_xsl_approval_flow`
ADD COLUMN `timeout_hours` int(11) NOT NULL DEFAULT 24 COMMENT '超时提醒小时数(0=不提醒)' AFTER `status_field`;

View File

@@ -1,94 +0,0 @@
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<FlowCapturedApi | null> {
// 若已有未完成的录制,先取消
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;
}
}

View File

@@ -19,9 +19,6 @@ 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();
@@ -93,9 +90,6 @@ 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不加前缀

View File

@@ -17,6 +17,9 @@ enum Api {
// 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审批流设计】当前页设计上下文-----
// update-begin---author:GHT ---date:2026-05-29 for【QH-MES审批流设计】业务表可选回调动作-----
bizActions = '/xslmes/approvalFlow/bizActions',
// update-end---author:GHT ---date:2026-05-29 for【QH-MES审批流设计】业务表可选回调动作-----
}
/**
@@ -73,3 +76,11 @@ export const batchDeleteApprovalFlow = (params, handleSuccess) => {
*/
export const getApprovalDesignContext = (routePath: string) => defHttp.get({ url: Api.designContext, params: { routePath } });
// update-end---author:GHT ---date:2026-05-29 for【QH-MES审批流设计】当前页设计上下文(解析阶段字段+取/建草稿流程)-----
// update-begin---author:GHT ---date:2026-05-29 for【QH-MES审批流设计】业务表可选回调动作(后端@ApprovalBizAction注解扫描)-----
/**
* 查询某业务表已标注 @ApprovalBizAction 的可选回调动作,供节点「回调接口」下拉选择。
* 返回 [{ name, url, method, table, phase, perms }]
*/
export const getApprovalBizActions = (table: string) => defHttp.get({ url: Api.bizActions, params: { table } });
// update-end---author:GHT ---date:2026-05-29 for【QH-MES审批流设计】业务表可选回调动作-----

View File

@@ -10,6 +10,9 @@ enum Api {
status = '/xslmes/approvalHandle/status',
approve = '/xslmes/approvalHandle/approve',
reject = '/xslmes/approvalHandle/reject',
// update-begin---author:GHT ---date:2026-05-29 for【QH-MES审批流设计】发起人撤销-----
cancel = '/xslmes/approvalHandle/cancel',
// update-end---author:GHT ---date:2026-05-29 for【QH-MES审批流设计】发起人撤销-----
}
/** 查看单据全部字段 + 审批进度/历史 */
@@ -23,3 +26,8 @@ export const approveApproval = (params: { instanceId: string; comment?: string }
/** 驳回(需填写理由) */
export const rejectApproval = (params: { instanceId: string; reason: string }) => defHttp.post({ url: Api.reject, data: params });
// update-begin---author:GHT ---date:2026-05-29 for【QH-MES审批流设计】发起人撤销-----
/** 撤销(仅发起人本人,审批中可撤回,业务单据恢复到发起时状态) */
export const cancelApproval = (params: { instanceId: string; reason?: string }) => defHttp.post({ url: Api.cancel, data: params });
// update-end---author:GHT ---date:2026-05-29 for【QH-MES审批流设计】发起人撤销-----

View File

@@ -4,7 +4,7 @@
@date 2026-05-29 forQH-MES审批流设计新增审批流可视化设计
-->
<template>
<BasicModal v-bind="$attrs" @register="registerModal" :title="modalTitle" defaultFullscreen :canFullscreen="false" :showOkBtn="!readonly" :okText="'保存并发布'" @ok="handleSave" :wrapClassName="flowApiRecording ? 'flow-design-recording-hide' : ''">
<BasicModal v-bind="$attrs" @register="registerModal" :title="modalTitle" defaultFullscreen :canFullscreen="false" :showOkBtn="!readonly" :okText="'保存并发布'" @ok="handleSave">
<div class="fd-design">
<div class="fd-toolbar">
<span class="fd-tb-item">绑定单据<b>{{ record.bizTableName || record.bizTable }}</b></span>
@@ -58,7 +58,6 @@
} from './flowTypes';
import type { FlowNode as FlowNodeType, NodeType, StageField } from './flowTypes';
import { saveApprovalFlowDesign, getApprovalFlowById } from '../approvalFlow.api';
import { flowApiRecording } from '/@/utils/flowApiRecorder';
defineOptions({ name: 'ApprovalFlowDesign' });
@@ -69,6 +68,9 @@
const readonly = ref(false);
const record = reactive<any>({ id: '', flowName: '', bizTable: '', bizTableName: '' });
const drawerRef = ref();
// 当前审批流绑定的业务表,供节点配置按表查可选回调动作
const bizTableRef = ref('');
provide('approvalBizTable', bizTableRef);
// update-begin---author:GHT ---date:2026-05-29 for【QH-MES审批流设计】当前页识别到的候选阶段字段----- -->
const paletteStages = ref<StageField[]>([]);
// update-end---author:GHT ---date:2026-05-29 for【QH-MES审批流设计】当前页识别到的候选阶段字段----- -->
@@ -105,6 +107,7 @@
const [registerModal, { setModalProps, closeModal }] = useModalInner(async (data) => {
readonly.value = !!data?.readonly;
flowCtx.readonly = readonly.value;
bizTableRef.value = data?.record?.bizTable || '';
// update-begin---author:GHT ---date:2026-05-29 for【QH-MES审批流设计】接收当前页解析出的候选阶段字段----- -->
paletteStages.value = Array.isArray(data?.paletteStages) ? data.paletteStages : [];
// update-end---author:GHT ---date:2026-05-29 for【QH-MES审批流设计】接收当前页解析出的候选阶段字段----- -->

View File

@@ -4,7 +4,7 @@
@date 2026-05-29 forQH-MES审批流设计新增审批流可视化设计
-->
<template>
<a-drawer :title="title" :width="480" :open="open && !flowApiRecording" @close="onClose" :maskClosable="!readonly">
<a-drawer :title="title" :width="480" :open="open" @close="onClose" :maskClosable="!readonly">
<template v-if="form">
<a-form layout="vertical">
<a-form-item label="节点名称">
@@ -80,13 +80,17 @@
</a-radio-group>
</a-form-item>
<!-- update-begin---author:GHT ---date:2026-05-29 forQH-MES审批流设计节点回调接口可视化配置(录制业务按钮接口)----- -->
<!-- update-begin---author:GHT ---date:2026-05-29 forQH-MES审批流设计节点回调接口可视化配置(自动识别页面按钮)----- -->
<a-divider style="margin: 16px 0 12px">回调接口审批联动业务</a-divider>
<a-alert
type="info"
show-icon
style="margin-bottom: 12px"
message="审批到对应时机时系统会以「当前处理人」身份调用所选业务接口自动带上单据ID。点击「录制」后设计器会临时隐藏请到页面上点击目标按钮如“批准”系统自动识别其接口并回填。"
:message="
pageActionOptions.length
? '以下为该业务已标注(@ApprovalBizAction的可选接口。审批到对应时机时系统会以「当前处理人」身份调用所选接口自动带上单据ID。'
: '该业务暂未标注可选接口(在后端 Controller 方法上加 @ApprovalBizAction 注解即可出现在此),也可手动填写接口路径。'
"
/>
<div v-for="phase in callbackPhases" :key="phase.key" class="fd-cb-block">
<div class="fd-cb-title">{{ phase.label }}</div>
@@ -96,15 +100,28 @@
<a-input v-model:value="a.url" placeholder="接口路径 /xxx" :disabled="readonly" style="flex: 1; min-width: 120px" />
<Icon v-if="!readonly" icon="ant-design:minus-circle-outlined" class="fd-cb-del" @click="removeAction(phase.key, i)" />
</div>
<a-space v-if="!readonly" style="margin-top: 4px">
<a-button size="small" @click="recordInto(phase.key)">
<Icon icon="ant-design:aim-outlined" />
<span>录制接口</span>
</a-button>
<a-space v-if="!readonly" style="margin-top: 4px" :size="6" wrap>
<a-select
v-if="pageActionOptions.length"
style="width: 240px"
placeholder="从页面按钮中选择…"
:options="pageActionOptions"
v-model:value="actionPicker[phase.key]"
show-search
option-filter-prop="label"
@select="(v) => addFromButton(phase.key, v)"
/>
<a-button size="small" @click="addAction(phase.key)">手动添加</a-button>
</a-space>
</div>
<!-- update-end---author:GHT ---date:2026-05-29 forQH-MES审批流设计节点回调接口可视化配置(录制业务按钮接口)----- -->
<!-- update-begin---author:GHT ---date:2026-05-29 forQH-MES审批流设计驳回统一回退,无需逐节点配置----- -->
<a-alert
type="success"
show-icon
style="margin-top: 4px"
message="驳回 / 撤销 已全局统一:系统会自动执行该业务标注为「驳回时执行(@ApprovalBizAction onReject)」的接口完成回退,无需在此逐节点、逐流程配置。"
/>
<!-- update-end---author:GHT ---date:2026-05-29 forQH-MES审批流设计驳回统一回退,无需逐节点配置----- -->
</template>
<!-- 抄送人 -->
@@ -173,20 +190,11 @@
</a-space>
</template>
</a-drawer>
<!-- update-begin---author:GHT ---date:2026-05-29 forQH-MES审批流设计录制中悬浮提示条----- -->
<Teleport to="body">
<div v-if="flowApiRecording" class="flow-record-banner">
<Icon icon="ant-design:aim-outlined" class="flow-record-banner-icon" />
<span class="flow-record-banner-text">录制中请点击页面上要绑定的业务按钮批准/反审核系统会自动识别其接口</span>
<a-button size="small" danger @click="cancelRecord">取消录制</a-button>
</div>
</Teleport>
<!-- update-end---author:GHT ---date:2026-05-29 forQH-MES审批流设计录制中悬浮提示条----- -->
</template>
<script lang="ts" setup>
import { computed, ref } from 'vue';
import { computed, ref, inject, watch } from 'vue';
import type { Ref } from 'vue';
import { cloneDeep } from 'lodash-es';
import { defHttp } from '/@/utils/http/axios';
import { ApiSelect } from '/@/components/Form';
@@ -194,25 +202,63 @@
import { OPERATOR_OPTIONS } from './flowTypes';
import type { FlowNode } from './flowTypes';
import { useMessage } from '/@/hooks/web/useMessage';
import { flowApiRecording, startFlowApiRecord, cancelFlowApiRecord } from '/@/utils/flowApiRecorder';
import { getApprovalBizActions } from '../approvalFlow.api';
const props = defineProps<{ readonly?: boolean }>();
const emit = defineEmits(['confirm']);
const { createMessage } = useMessage();
// 当前审批流绑定的业务表(由 FlowDesign 注入),据此向后端查可选回调动作
const bizTable = inject<Ref<string>>('approvalBizTable', ref(''));
// 后端 @ApprovalBizAction 标注的可选业务动作
const bizActions = ref<any[]>([]);
const bizActionsTable = ref('');
async function loadBizActions() {
const table = bizTable.value || '';
if (!table || bizActionsTable.value === table) {
return;
}
try {
const res = await getApprovalBizActions(table);
bizActions.value = Array.isArray(res) ? res : [];
bizActionsTable.value = table;
} catch (e) {
bizActions.value = [];
}
}
// 可选动作下拉项(含真实 url/method
const pageActionOptions = computed(() =>
(bizActions.value || []).map((a) => ({
label: `${a.name}${a.method} ${a.url}`,
value: a.url,
raw: a,
})),
);
watch(
bizTable,
() => {
loadBizActions();
},
{ immediate: true },
);
const open = ref(false);
const node = ref<FlowNode | null>(null);
const form = ref<any>(null);
// 「从页面按钮中选择」下拉的临时选中值(仅作选择器,选完即清空,避免跨节点残留上次的选项)
const actionPicker = ref<Record<string, any>>({ onNodeApprove: undefined, onApprove: undefined, onReject: undefined });
const operatorOptions = OPERATOR_OPTIONS;
const readonly = computed(() => !!props.readonly);
// 回调接口配置:三个触发时机
// 回调接口配置:通过类时机需按节点配置;驳回类(onReject)已全局统一(后端按 @ApprovalBizAction 自动执行),无需在此逐节点维护
const callbackPhases = [
{ key: 'onNodeApprove', label: '本节点通过时执行' },
{ key: 'onApprove', label: '流程最终通过时执行' },
{ key: 'onReject', label: '驳回时执行' },
];
const methodOptions = [
{ label: 'POST', value: 'POST' },
@@ -230,6 +276,8 @@
function openDrawer(n: FlowNode) {
node.value = n;
// 切换节点时清空下拉选择器的残留值
actionPicker.value = { onNodeApprove: undefined, onApprove: undefined, onReject: undefined };
// 编辑副本,确定时回写,避免取消后脏数据
form.value = { name: n.name, props: cloneDeep(n.props) };
// 审批人节点确保回调接口配置结构存在
@@ -252,17 +300,21 @@
form.value.props.callbackActions[phaseKey].splice(i, 1);
}
/** 录制:临时隐藏设计器,捕获用户点击业务按钮的请求并回填 */
async function recordInto(phaseKey: string) {
const cap = await startFlowApiRecord();
if (cap) {
form.value.props.callbackActions[phaseKey].push({ name: '', method: cap.method, url: cap.url, body: cap.data });
createMessage.success(`已录制接口:${cap.method} ${cap.url}`);
/** 从「已标注的业务动作」中选择一个接口加入回调(按 url 值查回原始动作) */
function addFromButton(phaseKey: string, url: string) {
const opt = pageActionOptions.value.find((o) => o.value === url);
const raw = opt?.raw;
if (!raw || !raw.url) {
return;
}
}
function cancelRecord() {
cancelFlowApiRecord();
const list = form.value.props.callbackActions[phaseKey];
// 选完即清空下拉,使其回到 placeholder仅作选择器用
actionPicker.value[phaseKey] = undefined;
if (list.some((a) => a.url === raw.url)) {
createMessage.info('该接口已添加');
return;
}
list.push({ name: raw.name, method: raw.method || 'POST', url: raw.url });
}
function onClose() {
@@ -327,33 +379,3 @@
flex-shrink: 0;
}
</style>
<!-- 录制提示条 Teleport body需非 scoped 全局样式 -->
<style lang="less">
.flow-record-banner {
position: fixed;
top: 16px;
left: 50%;
transform: translateX(-50%);
z-index: 99999;
display: flex;
align-items: center;
gap: 12px;
max-width: 92vw;
padding: 10px 16px;
border-radius: 8px;
background: #fffbe6;
border: 1px solid #ffe58f;
box-shadow: 0 6px 20px rgba(0, 0, 0, 0.15);
color: #614700;
font-size: 13px;
.flow-record-banner-icon {
color: #fa8c16;
font-size: 18px;
}
.flow-record-banner-text {
line-height: 1.5;
}
}
</style>

View File

@@ -5,11 +5,6 @@
*/
@line-color: #cacaca;
/* 录制回调接口时,隐藏整个设计器弹窗(含遮罩),让用户能点击业务页面上的真实按钮 */
.flow-design-recording-hide {
display: none !important;
}
.fd-design {
display: flex;
flex-direction: column;

View File

@@ -35,6 +35,13 @@
</template>
<!-- 不可办理(已处理/已流转/非当前处理人)置灰提示 -->
<span v-else-if="!props.mine && disabledText" class="im-biz-record-disabled">{{ disabledText }}</span>
<!-- 发起人审批中可撤销 -->
<a-button v-if="canCancel" size="small" danger :loading="cancelling" @click="openCancel(singleItem)">
<Icon icon="ant-design:rollback-outlined" />
<span>撤销</span>
</a-button>
<!-- 发起人审批已结束的状态提示 -->
<span v-else-if="mineEndedText" class="im-biz-record-disabled">{{ mineEndedText }}</span>
<a v-if="canLocate" class="im-biz-record-link" @click.prevent="handleLinkClick(singleItem.linkPath)">
<Icon icon="ant-design:unordered-list-outlined" />
<span>跳转至列表</span>
@@ -90,17 +97,30 @@
</a-form-item>
</a-form>
</a-modal>
<!-- 撤销弹窗 -->
<a-modal v-model:open="cancelOpen" title="撤销审批" :confirmLoading="cancelling" okText="确认撤销" @ok="confirmCancel">
<a-alert type="warning" show-icon :message="'撤销后流程将终止,单据将恢复到发起审批前的状态。'" style="margin-bottom: 12px" />
<a-form layout="vertical">
<a-form-item label="撤销原因">
<a-textarea v-model:value="cancelReason" :rows="3" placeholder="可选填写撤销原因" :maxlength="500" show-count />
</a-form-item>
</a-form>
</a-modal>
</div>
</template>
<script lang="ts" setup>
import { computed, ref, onMounted, watch } from 'vue';
import { computed, ref, onMounted, onBeforeUnmount, watch } from 'vue';
import type { ImBizRecordItem, ImBizRecordPayload } from './imBizRecordMessage';
import { getImBizRecordFieldValueByLabel, resolveImBizRecordItemFields, resolveImBizRecordListColumnLabels } from './imBizRecordMessage';
import { navigateImBizRecordLink } from './imRecordLocate';
import { hasImBizRecordPagePermission } from './imBizRecordPermission';
// update-begin---author:GHT ---date:2026-05-29 for【QH-MES审批流完善】订阅会话消息更新,撤销/流转后处理人卡片按钮实时置灰-----
import { onImMessagesUpdated } from './imCache';
// update-end---author:GHT ---date:2026-05-29 for【QH-MES审批流完善】订阅会话消息更新,撤销/流转后处理人卡片按钮实时置灰-----
import { useMessage } from '/@/hooks/web/useMessage';
import { approveApproval, rejectApproval, getApprovalStatus } from '/@/views/approval/flow/approvalHandle.api';
import { approveApproval, rejectApproval, cancelApproval, getApprovalStatus } from '/@/views/approval/flow/approvalHandle.api';
import ImApprovalDetailModal from './ImApprovalDetailModal.vue';
defineOptions({ name: 'ImBizRecordMessageContent' });
@@ -122,8 +142,14 @@
const rejectOpen = ref(false);
const rejectReason = ref('');
const rejectItem = ref<ImBizRecordItem | null>(null);
// 本地办理结果approved / rejected卡片消息为静态办理后本地标记
const actionDone = ref<'' | 'approved' | 'rejected'>('');
// 本地办理结果approved / rejected / cancelled(卡片消息为静态,办理后本地标记)
const actionDone = ref<'' | 'approved' | 'rejected' | 'cancelled'>('');
// update-begin---author:GHT ---date:2026-05-29 for【QH-MES审批流设计】发起人撤销-----
const cancelling = ref(false);
const cancelOpen = ref(false);
const cancelReason = ref('');
const cancelItem = ref<ImBizRecordItem | null>(null);
// update-end---author:GHT ---date:2026-05-29 for【QH-MES审批流设计】发起人撤销-----
// 审批实例实时状态(用于旧节点卡片置灰)
interface LiveStatus {
@@ -184,11 +210,38 @@
return '等待他人处理';
});
// update-begin---author:GHT ---date:2026-05-29 for【QH-MES审批流设计】发起人撤销-----
// 是否可撤销:审批卡片 且 本人发起(mine) 且 审批中 且 本地未撤销
const canCancel = computed(() => {
if (!isApprovalCard.value || !props.mine || actionDone.value) {
return false;
}
const s = liveStatus.value;
return !!s && s.exists === true && s.status === '0';
});
// 发起人视角的结束态提示
const mineEndedText = computed(() => {
if (!isApprovalCard.value || !props.mine) {
return '';
}
if (actionDone.value === 'cancelled') return '已撤销';
const s = liveStatus.value;
if (!s || !s.exists) return '';
if (s.status === '1') return '已通过';
if (s.status === '2') return '已驳回';
if (s.status === '3') return '已撤销';
return '';
});
// update-end---author:GHT ---date:2026-05-29 for【QH-MES审批流设计】发起人撤销-----
async function loadLiveStatus() {
const id = singleItem.value?.instanceId;
if (!isApprovalCard.value || props.mine || !id) {
// update-begin---author:GHT ---date:2026-05-29 for【QH-MES审批流设计】发起人卡片也需加载状态以支持撤销-----
if (!isApprovalCard.value || !id) {
return;
}
// update-end---author:GHT ---date:2026-05-29 for【QH-MES审批流设计】发起人卡片也需加载状态以支持撤销-----
try {
const res: any = await getApprovalStatus(id);
liveStatus.value = (res || { exists: false }) as LiveStatus;
@@ -202,6 +255,33 @@
onMounted(loadLiveStatus);
watch(() => singleItem.value?.instanceId, loadLiveStatus);
// update-begin---author:GHT ---date:2026-05-29 for【QH-MES审批流完善】订阅会话消息更新,撤销/流转后处理人卡片按钮实时置灰-----
// 撤销/驳回/流转时后端会向处理人推送IM消息WS到达会触发 onImMessagesUpdated
// 借此实时重新拉取实例状态,使本卡片的「审批/拒绝」按钮即时置灰、不可点击。
let unsubscribeMsgUpdated: (() => void) | null = null;
onMounted(() => {
if (!isApprovalCard.value) {
return;
}
unsubscribeMsgUpdated = onImMessagesUpdated(() => {
// 本地已办理的卡片无需再刷新,避免覆盖本地置灰文案
if (actionDone.value) {
return;
}
// 已是终态(已通过/已驳回/已撤销/已失效)的卡片无需重复拉取
const s = liveStatus.value;
if (s && (s.exists === false || (!!s.status && s.status !== '0'))) {
return;
}
loadLiveStatus();
});
});
onBeforeUnmount(() => {
unsubscribeMsgUpdated?.();
unsubscribeMsgUpdated = null;
});
// update-end---author:GHT ---date:2026-05-29 for【QH-MES审批流完善】订阅会话消息更新,撤销/流转后处理人卡片按钮实时置灰-----
const hasPagePermission = computed(() => hasImBizRecordPagePermission(props.payload.pagePath));
// 审批卡片即使无列表页权限也应可办理/查看详情,仅"跳转至列表"受页面权限约束
const showNoPermission = computed(() => !props.mine && !hasPagePermission.value && !isApprovalCard.value);
@@ -231,6 +311,13 @@
async function handleApprove(item: ImBizRecordItem) {
if (!item.instanceId || approving.value) return;
// update-begin---author:GHT ---date:2026-05-29 for【QH-MES审批流完善】办理前二次校验最新状态,撤销后防误点击-----
await loadLiveStatus();
if (!liveActionable.value) {
createMessage.warning(disabledText.value || '该审批已无法办理');
return;
}
// update-end---author:GHT ---date:2026-05-29 for【QH-MES审批流完善】办理前二次校验最新状态,撤销后防误点击-----
try {
approving.value = true;
const res: any = await approveApproval({ instanceId: item.instanceId });
@@ -257,6 +344,14 @@
createMessage.warning('请填写驳回理由');
return;
}
// update-begin---author:GHT ---date:2026-05-29 for【QH-MES审批流完善】办理前二次校验最新状态,撤销后防误点击-----
await loadLiveStatus();
if (!liveActionable.value) {
createMessage.warning(disabledText.value || '该审批已无法办理');
rejectOpen.value = false;
return;
}
// update-end---author:GHT ---date:2026-05-29 for【QH-MES审批流完善】办理前二次校验最新状态,撤销后防误点击-----
try {
rejecting.value = true;
await rejectApproval({ instanceId: item.instanceId, reason: rejectReason.value.trim() });
@@ -269,6 +364,30 @@
rejecting.value = false;
}
}
// update-begin---author:GHT ---date:2026-05-29 for【QH-MES审批流设计】发起人撤销-----
function openCancel(item: ImBizRecordItem) {
cancelItem.value = item;
cancelReason.value = '';
cancelOpen.value = true;
}
async function confirmCancel() {
const item = cancelItem.value;
if (!item?.instanceId || cancelling.value) return;
try {
cancelling.value = true;
await cancelApproval({ instanceId: item.instanceId, reason: cancelReason.value.trim() });
createMessage.success('已撤销,单据已恢复到发起前状态');
actionDone.value = 'cancelled';
cancelOpen.value = false;
await loadLiveStatus();
emit('handled');
} finally {
cancelling.value = false;
}
}
// update-end---author:GHT ---date:2026-05-29 for【QH-MES审批流设计】发起人撤销-----
</script>
<style lang="less" scoped>