|
|
|
|
@ -208,10 +208,17 @@ public class ErpTempTaskServiceImpl implements IErpTempTaskService {
|
|
|
|
|
return params != null && Convert.toBool(params.get("includeDetail"), false);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Why:批量聚合优化——分页列表查询后,一次性批量拉取所有记录的参与人/工时/评分明细,
|
|
|
|
|
* 而非在每条记录上逐个 query(N+1 问题)。通过 taskIds IN(...) 将三次 DB 查询降为三次,
|
|
|
|
|
* 然后用 Map 分组归集到各 record,大幅降低 DB 往返开销。
|
|
|
|
|
* 评分可见性隔离在此循环内直接过滤,而非单独调用 listVisibleScore(避免二次 DB 查询)。
|
|
|
|
|
*/
|
|
|
|
|
private void fillDetailRows(List<ErpTempTaskVo> records) {
|
|
|
|
|
if (records == null || records.isEmpty()) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
// Why:提取所有 taskId,作为批量 IN 查询的 key 集合。
|
|
|
|
|
List<Long> taskIds = records.stream()
|
|
|
|
|
.map(ErpTempTaskVo::getTempTaskId)
|
|
|
|
|
.filter(Objects::nonNull)
|
|
|
|
|
@ -220,6 +227,7 @@ public class ErpTempTaskServiceImpl implements IErpTempTaskService {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Why:一次 DB 查询拉取所有记录的参与人列表,构建 memberMap(按 taskId 分组)和 memberNameMap(按 memberId 映射姓名)。
|
|
|
|
|
List<ErpTempTaskMemberVo> members = memberMapper.selectVoList(Wrappers.<ErpTempTaskMember>lambdaQuery()
|
|
|
|
|
.in(ErpTempTaskMember::getTempTaskId, taskIds)
|
|
|
|
|
.orderByAsc(ErpTempTaskMember::getMemberType));
|
|
|
|
|
@ -230,6 +238,7 @@ public class ErpTempTaskServiceImpl implements IErpTempTaskService {
|
|
|
|
|
memberNameMap.computeIfAbsent(member.getTempTaskId(), key -> new HashMap<>()).put(member.getMemberId(), member.getUserName());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Why:一次 DB 查询拉取所有工时明细,按 taskId 分组并回填填报人姓名(从 memberNameMap 取)。
|
|
|
|
|
List<ErpTempTaskWorklogVo> worklogs = worklogMapper.selectVoList(Wrappers.<ErpTempTaskWorklog>lambdaQuery()
|
|
|
|
|
.in(ErpTempTaskWorklog::getTempTaskId, taskIds)
|
|
|
|
|
.orderByAsc(ErpTempTaskWorklog::getWeekStart)
|
|
|
|
|
@ -242,14 +251,19 @@ public class ErpTempTaskServiceImpl implements IErpTempTaskService {
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Long uid = LoginHelper.getUserId();
|
|
|
|
|
// Why:构建 leaderMap(taskId→softwareLeaderId),用于评分可见性判断。
|
|
|
|
|
Map<Long, Long> leaderMap = new HashMap<>(taskIds.size());
|
|
|
|
|
for (ErpTempTaskVo record : records) {
|
|
|
|
|
leaderMap.put(record.getTempTaskId(), record.getSoftwareLeaderId());
|
|
|
|
|
}
|
|
|
|
|
// Why:一次 DB 查询拉取所有评分明细,在应用层循环中进行**评分可见性过滤**:
|
|
|
|
|
// 领导(softwareLeaderId)看全部评分,被评分人自己只看到自己被评的分数,其他人看不到任何评分记录(AD-16)。
|
|
|
|
|
// 此处 inline 过滤而非调用 listVisibleScore,避免对每条记录再次查 DB。
|
|
|
|
|
List<ErpTempTaskScoreVo> scores = scoreMapper.selectVoList(Wrappers.<ErpTempTaskScore>lambdaQuery()
|
|
|
|
|
.in(ErpTempTaskScore::getTempTaskId, taskIds));
|
|
|
|
|
Map<Long, List<ErpTempTaskScoreVo>> scoreMap = new HashMap<>(taskIds.size());
|
|
|
|
|
for (ErpTempTaskScoreVo score : scores) {
|
|
|
|
|
// Why:可见性隔离——只有领导(softwareLeaderId)或被评分人本人才能看到评分。
|
|
|
|
|
boolean visible = Objects.equals(uid, leaderMap.get(score.getTempTaskId())) || Objects.equals(uid, score.getUserId());
|
|
|
|
|
if (!visible) {
|
|
|
|
|
continue;
|
|
|
|
|
@ -259,6 +273,7 @@ public class ErpTempTaskServiceImpl implements IErpTempTaskService {
|
|
|
|
|
scoreMap.computeIfAbsent(score.getTempTaskId(), key -> new ArrayList<>()).add(score);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Why:将已分组的数据 set 到每条 VO 上,供前端直接渲染。
|
|
|
|
|
for (ErpTempTaskVo record : records) {
|
|
|
|
|
Long taskId = record.getTempTaskId();
|
|
|
|
|
record.setMembers(memberMap.getOrDefault(taskId, Collections.emptyList()));
|
|
|
|
|
@ -325,22 +340,23 @@ public class ErpTempTaskServiceImpl implements IErpTempTaskService {
|
|
|
|
|
* @return 发起后的最新任务详情
|
|
|
|
|
*/
|
|
|
|
|
@Override
|
|
|
|
|
// Why:@GlobalTransactional 保证业务数据保存→流程发起→办结首任务节点在同一 Seata 分布式事务内原子提交或整体回滚,
|
|
|
|
|
// 避免出现”业务数据已入库但流程实例未创建”或”流程实例已创建但业务数据回滚”这类半一致状态。
|
|
|
|
|
@GlobalTransactional(rollbackFor = Exception.class)
|
|
|
|
|
public ErpTempTaskVo tempTaskSubmitAndFlowStart(ErpTempTaskBo bo) {
|
|
|
|
|
// 1. 发起提交前校验:任务描述与需求时间非空,且紧急优先级必须有紧急原因
|
|
|
|
|
validBeforeSubmit(bo);
|
|
|
|
|
// 2. 补齐草稿字段,如果是首次直接提交则在此节点生成 LSRW 唯一编号
|
|
|
|
|
prepareDraft(bo);
|
|
|
|
|
// 双状态机制初始化:任务提交后先进入审批中,后续执行/待审核/关闭由业务接口或流程节点显式推进。
|
|
|
|
|
// Why:双状态机制初始化——taskStatus(2-审批中) 控制前端页面交互与业务语义;flowStatus(waiting) 同步 Warm-Flow 引擎,
|
|
|
|
|
// 二者独立维护但联动,后续 execute/leader_final/关闭 由业务接口或流程事件精确推进,避免笼统写死。
|
|
|
|
|
bo.setTaskStatus(STATUS_APPROVING);
|
|
|
|
|
bo.setFlowStatus(BusinessStatusEnum.WAITING.getStatus());
|
|
|
|
|
|
|
|
|
|
boolean saved;
|
|
|
|
|
// 3. 根据 ID 判断是首次直接提交还是基于已有草稿提交,执行相应的落库:
|
|
|
|
|
// - 若 tempTaskId 为 null:说明用户未存草稿,直接在页面点击了"提交审批"并发起流程。
|
|
|
|
|
// 需将其转换为实体,进行入库前常规校验并插入数据库生成主键 ID,再反写回 BO。
|
|
|
|
|
// - 若 tempTaskId 不为 null:说明是由已有草稿发起提交,或者是流程退回、撤销后重新提交。
|
|
|
|
|
// 需校验编辑权限 checkEditable 避免越权与非法篡改,再进行合法性校验并更新数据库记录。
|
|
|
|
|
// Why:两分支覆盖”直接提交”与”草稿后提交”:
|
|
|
|
|
// - tempTaskId==null:用户未存草稿直接点”提交审批”,需先 insert 获取主键 ID 作为流程 businessId;
|
|
|
|
|
// - tempTaskId!=null:基于已有草稿提交或退回/撤销后重新提交,走 update 保留原有 ID,但必须先 checkEditable 防止越权篡改。
|
|
|
|
|
if (bo.getTempTaskId() == null) {
|
|
|
|
|
ErpTempTask add = MapstructUtils.convert(bo, ErpTempTask.class);
|
|
|
|
|
validEntityBeforeSave(add);
|
|
|
|
|
@ -352,31 +368,54 @@ public class ErpTempTaskServiceImpl implements IErpTempTaskService {
|
|
|
|
|
validEntityBeforeSave(update);
|
|
|
|
|
saved = baseMapper.updateById(update) > 0;
|
|
|
|
|
}
|
|
|
|
|
// 如果数据未成功保存或未反写 ID,则直接抛出 ServiceException 异常回滚事务,防止没有绑定业务 ID 的“悬空流程实例”产生
|
|
|
|
|
// Why:保存失败或无 businessId 时立即抛异常触发 Seata 回滚,防止”悬空流程实例”(没有绑定业务数据的流程记录,后续无法闭环)。
|
|
|
|
|
if (!saved || bo.getTempTaskId() == null) {
|
|
|
|
|
throw new ServiceException("临时任务保存失败,无法发起流程");
|
|
|
|
|
}
|
|
|
|
|
// V3:发起时若已指定主执行人,同步参与人表主执行人记录(AD-13)
|
|
|
|
|
// Why:发起时若已指定主执行人,同步写入参与人表(AD-13)。放在落库后而非落库前,确保 taskId 已生成。
|
|
|
|
|
refreshMainAssigneeMember(bo.getTempTaskId(), bo.getAssigneeId(), bo.getAssigneeName());
|
|
|
|
|
|
|
|
|
|
// 4. 组装 Warm-Flow 远程流程启动对象
|
|
|
|
|
RemoteStartProcess startProcess = new RemoteStartProcess();
|
|
|
|
|
startProcess.setBusinessId(bo.getTempTaskId().toString());
|
|
|
|
|
startProcess.setFlowCode(FLOW_CODE); // 固定编码 OATT
|
|
|
|
|
// 构建用于流程走向/办理人解析的最小 variables Map
|
|
|
|
|
// Why:variables 只携带流程路由(has_assignee 网关)+ 办理人解析(assigneeId/softwareLeaderId/requesterId)所需最小字段,
|
|
|
|
|
// 不将完整业务对象塞入,避免流程变量表膨胀并降低序列化风险。ignore=true 仅在发起流程时使用,让引擎自动跳过已办完的申请人首节点。
|
|
|
|
|
startProcess.setVariables(buildWorkflowVariables(bo, true));
|
|
|
|
|
|
|
|
|
|
// 5. 补充待办中心/待办列表所需的业务展示扩展字段,包含编号和拼接后的标题
|
|
|
|
|
// Why:bizExt 是审批中心/待办列表的展示用快照,存入流程实例表供前端列表渲染,不做流程路由决策。
|
|
|
|
|
bo.getBizExt().setBusinessId(startProcess.getBusinessId());
|
|
|
|
|
bo.getBizExt().setBusinessCode(bo.getTempTaskCode());
|
|
|
|
|
bo.getBizExt().setBusinessTitle(buildBusinessTitle(bo));
|
|
|
|
|
startProcess.setBizExt(bo.getBizExt());
|
|
|
|
|
|
|
|
|
|
// 6. 远程调用工作流服务发起并完成申请人节点
|
|
|
|
|
// Why:startCompleteTask 一步完成"创建流程实例 → 自动完成首节点(申请人)",避免申请人看到自己的待办。
|
|
|
|
|
// 失败直接抛业务异常触发全局回滚,确保不会产生半拉流程记录。
|
|
|
|
|
boolean started = remoteWorkflowService.startCompleteTask(startProcess);
|
|
|
|
|
if (!started) {
|
|
|
|
|
throw new ServiceException("流程发起异常");
|
|
|
|
|
}
|
|
|
|
|
// Why:Warm-Flow 的 anyNodeSkip:"submit" 机制——发起人同时是主执行人或软件部领导时,
|
|
|
|
|
// 工作流引擎会自动跳过 assignee_review 和/或 leader_review 节点直达 execute。
|
|
|
|
|
// 自动跳过完全在引擎内部完成,绕过了 buildLeaderReviewVariables。
|
|
|
|
|
// 因此必须在发起后补刀 taskStatus,否则前端 canExecute 不会出现。
|
|
|
|
|
// 条件:①指定了主执行人 + 发起人==主执行人==领导 → 两节点全跳过 → 直达 execute
|
|
|
|
|
// ②未指定主执行人 + 发起人==领导 → 跳过 leader_review → 直达 execute
|
|
|
|
|
boolean initiatorIsAssignee = bo.getAssigneeId() != null
|
|
|
|
|
&& Objects.equals(bo.getRequesterId(), bo.getAssigneeId());
|
|
|
|
|
boolean initiatorIsLeader = Objects.equals(bo.getRequesterId(), bo.getSoftwareLeaderId());
|
|
|
|
|
boolean autoPassToExecute = (initiatorIsAssignee && initiatorIsLeader)
|
|
|
|
|
|| (bo.getAssigneeId() == null && initiatorIsLeader);
|
|
|
|
|
if (autoPassToExecute) {
|
|
|
|
|
ErpTempTask task = baseMapper.selectById(bo.getTempTaskId());
|
|
|
|
|
if (task != null && !STATUS_RUNNING.equals(task.getTaskStatus())) {
|
|
|
|
|
task.setTaskStatus(STATUS_RUNNING);
|
|
|
|
|
task.setActualStartTime(new Date());
|
|
|
|
|
baseMapper.updateById(task);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return baseMapper.selectCustomErpTempTaskVoById(bo.getTempTaskId());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
@ -442,8 +481,9 @@ public class ErpTempTaskServiceImpl implements IErpTempTaskService {
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 临时任务流程扭转事件监听 (传统范式回写)
|
|
|
|
|
* 使用 `@EventListener` 隔离工作流框架。仅在状态流转时,回写流程状态 `flow_status` 与粗粒度的业务状态 `task_status`。
|
|
|
|
|
* 人员评分与工时冻结由业务接口显式控制,流程事件只做双状态同步。
|
|
|
|
|
* Why:使用 @EventListener 而非实现接口,业务模块自建监听器精确匹配本模块 flowCode,
|
|
|
|
|
* 仅做双状态(flowStatus + taskStatus)回写,不耦合工作流框架内部实现。
|
|
|
|
|
* 人员评分与工时冻结由业务接口显式控制,流程事件只做状态同步——单一职责。
|
|
|
|
|
*/
|
|
|
|
|
@EventListener(condition = "#processEvent.flowCode == 'OATT'")
|
|
|
|
|
public void processHandler(ProcessEvent processEvent) {
|
|
|
|
|
@ -458,11 +498,14 @@ public class ErpTempTaskServiceImpl implements IErpTempTaskService {
|
|
|
|
|
String status = processEvent.getStatus();
|
|
|
|
|
String nodeCode = processEvent.getNodeCode();
|
|
|
|
|
|
|
|
|
|
// 2) 业务状态按"流程状态 + 当前节点"精确映射(AD-15):避免执行/待审核态被笼统回写成审批中
|
|
|
|
|
// Why:WAITING 三路分叉——根据当前节点精确映射业务状态以避免"审批中"笼统覆盖:
|
|
|
|
|
// - execute 节点:流程流转到"执行"节点 → taskStatus=3(执行中) 让主执行人可填工时
|
|
|
|
|
// - leader_final 节点:流程流转到"领导最终审核" → taskStatus=4(待领导审核) 冻结工时只读
|
|
|
|
|
// - 其他审批节点(leader_review/cc):保持 taskStatus=2(审批中),前端只展示审批界面
|
|
|
|
|
if (Objects.equals(status, BusinessStatusEnum.WAITING.getStatus())) {
|
|
|
|
|
// Why:执行节点=执行中(3),领导评分节点=待领导审核(4),其余审批节点=审批中(2)
|
|
|
|
|
if ("execute".equals(nodeCode)) {
|
|
|
|
|
task.setTaskStatus(STATUS_RUNNING);
|
|
|
|
|
// Why:首次进入执行节点自动打上实际开始时间戳,用于后续提前/按期/延期口径计算(AD-06)。
|
|
|
|
|
if (task.getActualStartTime() == null) {
|
|
|
|
|
task.setActualStartTime(new Date());
|
|
|
|
|
}
|
|
|
|
|
@ -472,7 +515,8 @@ public class ErpTempTaskServiceImpl implements IErpTempTaskService {
|
|
|
|
|
task.setTaskStatus(STATUS_APPROVING);
|
|
|
|
|
}
|
|
|
|
|
} else if (Objects.equals(status, BusinessStatusEnum.FINISH.getStatus())) {
|
|
|
|
|
// Why:流程结束=领导评分关闭;scoreAndClose 已显式置5,此处兜底防止漏写
|
|
|
|
|
// Why:流程结束=领导评分关闭;scoreAndClose 已显式置 taskStatus=5,此处兜底防御
|
|
|
|
|
// 防止流程因异常路径(如直接流程强制结束)FINISH 但 taskStatus 未闭环的情况。
|
|
|
|
|
if (!STATUS_CLOSED.equals(task.getTaskStatus())) {
|
|
|
|
|
task.setTaskStatus(STATUS_CLOSED);
|
|
|
|
|
}
|
|
|
|
|
@ -480,9 +524,15 @@ public class ErpTempTaskServiceImpl implements IErpTempTaskService {
|
|
|
|
|
|| Objects.equals(status, BusinessStatusEnum.TERMINATION.getStatus())) {
|
|
|
|
|
task.setTaskStatus(STATUS_INVALID); // 6 作废
|
|
|
|
|
} else if (Objects.equals(status, BusinessStatusEnum.BACK.getStatus())) {
|
|
|
|
|
// Why:领导最终审核驳回应回执行中并解锁工时,不能依赖 Warm-Flow 版本对 nodeCode 源/目标节点的取值差异。
|
|
|
|
|
// Why:退回分支——根据当前 taskStatus 和 nodeCode 决定回退到哪个状态:
|
|
|
|
|
// - STATUS_PENDING_FINAL(=4) 或 nodeCode=execute:说明是从"领导最终审核"或"执行"节点退回,
|
|
|
|
|
// 应回到 taskStatus=3(执行中) 并 unlock 工时明细,让主执行人继续维护;
|
|
|
|
|
// - 其他情况(如从 leader_review 退回):回到 taskStatus=1(暂存) 让发起人重新编辑。
|
|
|
|
|
// 注意:不能依赖 Warm-Flow 版本对 nodeCode 源/目标节点的取值差异,
|
|
|
|
|
// 需同时以 taskStatus 做辅助判断,确保倒退后的业务状态一致且工时锁释放。
|
|
|
|
|
if (STATUS_PENDING_FINAL.equals(task.getTaskStatus()) || "execute".equals(nodeCode)) {
|
|
|
|
|
task.setTaskStatus(STATUS_RUNNING);
|
|
|
|
|
// Why:退回时必须 unlock 工时明细,否则提交完成时锁定的工时无法继续填写(AD-15)。
|
|
|
|
|
worklogMapper.lockByTask(task.getTempTaskId(), "0");
|
|
|
|
|
} else {
|
|
|
|
|
task.setTaskStatus(STATUS_DRAFT);
|
|
|
|
|
@ -553,22 +603,31 @@ public class ErpTempTaskServiceImpl implements IErpTempTaskService {
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 构建用于发起流程及流转所携带的 variables 变量 Map
|
|
|
|
|
* variables 只存放新版 OATT 流程分支(has_assignee/reassigned)与办理人解析(softwareLeaderId/assigneeId)必须的属性。
|
|
|
|
|
* Why:variables 只存放新版 OATT 流程分支(has_assignee/reassigned)与办理人解析(softwareLeaderId/assigneeId)必须的属性。
|
|
|
|
|
* 变量来源优先级:BO 直接字段 > bo.sourceVariables(历史变量)。先取主后取备用。
|
|
|
|
|
*/
|
|
|
|
|
private Map<String, Object> buildWorkflowVariables(ErpTempTaskBo bo) {
|
|
|
|
|
return buildWorkflowVariables(bo, false);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Why:ignorePermission 仅在流程发起(startCompleteTask)时设为 true,用于引擎自动完成申请人节点时
|
|
|
|
|
* 跳过"办理人必须是当前登录人"的校验。后续真人办理(leader_review/execute/leader_final)的 completeTask
|
|
|
|
|
* 调用不传 ignore=true,以确保只有指定的办理人才能推进流程,防止越权操作。
|
|
|
|
|
*/
|
|
|
|
|
private Map<String, Object> buildWorkflowVariables(ErpTempTaskBo bo, boolean ignorePermission) {
|
|
|
|
|
Map<String, Object> sourceVariables = bo.getVariables();
|
|
|
|
|
Map<String, Object> variables = new HashMap<>();
|
|
|
|
|
if (ignorePermission) {
|
|
|
|
|
variables.put("ignore", true); // 仅流程发起时自动完成申请人节点使用,真人办理节点不得绕过办理人校验
|
|
|
|
|
// Why:仅流程发起时设为 true,让引擎自动完成申请人节点时绕过办理人权限校验。
|
|
|
|
|
variables.put("ignore", true);
|
|
|
|
|
}
|
|
|
|
|
boolean hasAssignee = bo.getAssigneeId() != null || sourceVariables.get("assigneeId") != null;
|
|
|
|
|
variables.put("has_assignee", hasAssignee ? "1" : "0");
|
|
|
|
|
variables.put("reassigned", StringUtils.blankToDefault(Convert.toStr(sourceVariables.get("reassigned")), "0"));
|
|
|
|
|
|
|
|
|
|
// Why:变量来源——先取 BO 直接字段(前端/业务接口最新值),为 null 时 fallback 到 sourceVariables 历史值,
|
|
|
|
|
// 保证变量不丢失且不覆盖为 null。
|
|
|
|
|
putIfPresent(variables, "taskTitle", firstNonNull(bo.getTaskTitle(), sourceVariables.get("taskTitle")));
|
|
|
|
|
putIfPresent(variables, "taskDesc", firstNonNull(bo.getTaskDesc(), sourceVariables.get("taskDesc")));
|
|
|
|
|
putIfPresent(variables, "taskType", firstNonNull(bo.getTaskType(), sourceVariables.get("taskType")));
|
|
|
|
|
@ -586,8 +645,8 @@ public class ErpTempTaskServiceImpl implements IErpTempTaskService {
|
|
|
|
|
putIfPresent(variables, "leaderOpinion", firstNonNull(bo.getLeaderOpinion(), sourceVariables.get("leaderOpinion")));
|
|
|
|
|
putIfPresent(variables, "actualStartTime", firstNonNull(bo.getActualStartTime(), sourceVariables.get("actualStartTime")));
|
|
|
|
|
putIfPresent(variables, "leaderFinalOpinion", firstNonNull(bo.getLeaderFinalOpinion(), sourceVariables.get("leaderFinalOpinion")));
|
|
|
|
|
putIfPresent(variables, "ccUserIds", firstNonNull(bo.getCcUserIds(), sourceVariables.get("ccUserIds")));
|
|
|
|
|
// 清理空值项,防止序列化问题
|
|
|
|
|
// putIfPresent(variables, "ccUserIds", firstNonNull(bo.getCcUserIds(), sourceVariables.get("ccUserIds")));
|
|
|
|
|
// Why:清理 null 值条目,防止流程变量序列化/反序列化时 null 值导致类型推断错误或 JSON 清理逻辑不一致。
|
|
|
|
|
variables.entrySet().removeIf(entry -> Objects.isNull(entry.getValue()));
|
|
|
|
|
return variables;
|
|
|
|
|
}
|
|
|
|
|
@ -650,26 +709,34 @@ public class ErpTempTaskServiceImpl implements IErpTempTaskService {
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 同步参与人表中的"主执行人"记录
|
|
|
|
|
* Why:主执行人随流程流转(发起指定/领导换人),需保证参与人表恒有且仅有一条主执行人记录,
|
|
|
|
|
* 且若新主执行人此前是协作人需先移除其协作记录,避免 (task,user) 唯一约束冲突(AD-12/AD-13)。
|
|
|
|
|
* Why:主执行人随流程流转变化(发起指定/领导审批换人),需保证参与人表中始终有且仅有一条 memberType='1' 的主执行人记录。
|
|
|
|
|
* 唯一性保证三步策略:
|
|
|
|
|
* 1. 若旧主执行人与新 assigneeId 相同 → 跳过处理(幂等,避免无效删除/插入)。
|
|
|
|
|
* 2. 若不同 → 先 delete 旧主执行人记录。
|
|
|
|
|
* 3. **关键**:在插入新主执行人前,必须先 delete 该用户在本任务下的协作记录(memberType='2')。
|
|
|
|
|
* 因为参与人表存在 (tempTaskId, userId) 唯一约束,若新主执行人此前已是协作人,
|
|
|
|
|
* 直接 insert(memberType='1') 会违反 UK 冲突。先删协作记录再插主执行人 = 升级身份且不破坏唯一约束(AD-12/AD-13)。
|
|
|
|
|
*/
|
|
|
|
|
private void refreshMainAssigneeMember(Long taskId, Long assigneeId, String assigneeName) {
|
|
|
|
|
if (taskId == null || assigneeId == null) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
// 1. 查询当前主执行人记录
|
|
|
|
|
ErpTempTaskMember existMain = memberMapper.selectOne(Wrappers.<ErpTempTaskMember>lambdaQuery()
|
|
|
|
|
.eq(ErpTempTaskMember::getTempTaskId, taskId)
|
|
|
|
|
.eq(ErpTempTaskMember::getMemberType, MEMBER_TYPE_MAIN));
|
|
|
|
|
if (existMain != null) {
|
|
|
|
|
if (Objects.equals(existMain.getUserId(), assigneeId)) {
|
|
|
|
|
return; // 主执行人未变更,无需处理
|
|
|
|
|
return; // Why:主执行人未变更,幂等跳过,避免不必要的 delete+insert。
|
|
|
|
|
}
|
|
|
|
|
// 2. 旧主执行人 ≠ 新主执行人,移除旧记录
|
|
|
|
|
memberMapper.deleteById(existMain.getMemberId());
|
|
|
|
|
}
|
|
|
|
|
// 若新主执行人此前以协作人身份参与,先移除其协作记录,规避唯一约束冲突
|
|
|
|
|
// 3. Why:若新主执行人此前以协作人身份参与,先移除其协作记录,规避 (taskId,userId) 唯一约束冲突。
|
|
|
|
|
memberMapper.delete(Wrappers.<ErpTempTaskMember>lambdaQuery()
|
|
|
|
|
.eq(ErpTempTaskMember::getTempTaskId, taskId)
|
|
|
|
|
.eq(ErpTempTaskMember::getUserId, assigneeId));
|
|
|
|
|
// 4. 插入新主执行人记录
|
|
|
|
|
ErpTempTaskMember main = new ErpTempTaskMember();
|
|
|
|
|
main.setTempTaskId(taskId);
|
|
|
|
|
main.setMemberType(MEMBER_TYPE_MAIN);
|
|
|
|
|
@ -680,7 +747,11 @@ public class ErpTempTaskServiceImpl implements IErpTempTaskService {
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 主执行人审阅提交:无论是否修改,提交后都回领导审批(AD-12)
|
|
|
|
|
* Why:仅当前主执行人可审阅;用选择性回写而非整体覆盖,避免清空流程态字段。流程流转由前端 submitVerify→completeTask 承载。
|
|
|
|
|
* Why:仅当前主执行人可审阅,避免协作人或发起人篡改。
|
|
|
|
|
* Why:采用**选择性字段回写**而非整体 Mapstruct 覆盖——只更新任务描述/计划时间/预估工时等少量允许修改的字段,
|
|
|
|
|
* 保留 taskStatus/flowStatus/assigneeId/softwareLeaderId 等核心流程控制字段不变,
|
|
|
|
|
* 防止前端传入的 BO 对象意外覆盖掉这些由后端流程事件维护的状态字段。
|
|
|
|
|
* 流程流转(退回领导审批)由前端 submitVerify 组件调用 completeTask 承载,本方法仅做业务数据保存。
|
|
|
|
|
*/
|
|
|
|
|
@Override
|
|
|
|
|
public Boolean assigneeReviewSubmit(ErpTempTaskBo bo) {
|
|
|
|
|
@ -691,6 +762,7 @@ public class ErpTempTaskServiceImpl implements IErpTempTaskService {
|
|
|
|
|
if (!Objects.equals(LoginHelper.getUserId(), task.getAssigneeId())) {
|
|
|
|
|
throw new ServiceException("只有主执行人可以审阅提交");
|
|
|
|
|
}
|
|
|
|
|
// Why:逐一判断不为空再 set,仅回写主执行人有权修改的字段,其余字段(taskStatus/flowStatus/assigneeId 等)保持不变。
|
|
|
|
|
if (StringUtils.isNotBlank(bo.getTaskTitle())) {
|
|
|
|
|
task.setTaskTitle(bo.getTaskTitle());
|
|
|
|
|
}
|
|
|
|
|
@ -712,7 +784,11 @@ public class ErpTempTaskServiceImpl implements IErpTempTaskService {
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 领导审批:回写领导可修改的任务信息,并计算是否换主执行人。
|
|
|
|
|
* Why:reassigned 必须以后端持久化前后的主执行人差异为准,不能信任前端缓存变量。
|
|
|
|
|
* Why:reassigned 必须以后端持久化前后的主执行人差异为准(oldAssigneeId != newAssigneeId),
|
|
|
|
|
* 不能信任前端传入的 variables.reassigned,防止客户端伪造或缓存滞后导致流程网关分支错判。
|
|
|
|
|
* Why:此处用 @Transactional 而非 @GlobalTransactional,因为此方法是被 leaderReviewAndComplete 内部调用的私有步骤,
|
|
|
|
|
* leaderReviewAndComplete 已标注 @GlobalTransactional,内层 @Transactional 会渗入外层 Seata 全局事务,
|
|
|
|
|
* 无需重复标注避免事务管理器混淆(Spring 本地事务与 Seata 全局事务兼容嵌套,但外层已统一管控)。
|
|
|
|
|
*/
|
|
|
|
|
@Override
|
|
|
|
|
@Transactional(rollbackFor = Exception.class)
|
|
|
|
|
@ -731,21 +807,39 @@ public class ErpTempTaskServiceImpl implements IErpTempTaskService {
|
|
|
|
|
throw new ServiceException("进入执行前必须指定主执行人");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Why:持久化前先快照旧主执行人 ID,用于后续对比判断 reassigned。
|
|
|
|
|
Long oldAssigneeId = task.getAssigneeId();
|
|
|
|
|
applyLeaderReviewFields(task, bo);
|
|
|
|
|
// Why:reassigned=0 意味着领导没有换主执行人,下一节点直接进入 execute(执行),
|
|
|
|
|
// 在此提前置 taskStatus=3(执行中),避免依赖跨 JVM 的 ProcessEvent 回写不及时。
|
|
|
|
|
// processHandler 仍作为兜底,但此处是业务主路径。
|
|
|
|
|
boolean willEnterExecute = Objects.equals(oldAssigneeId, task.getAssigneeId());
|
|
|
|
|
if (willEnterExecute) {
|
|
|
|
|
task.setTaskStatus(STATUS_RUNNING);
|
|
|
|
|
if (task.getActualStartTime() == null) {
|
|
|
|
|
task.setActualStartTime(new Date());
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
validEntityBeforeSave(task);
|
|
|
|
|
baseMapper.updateById(task);
|
|
|
|
|
// Why:任务类型变更后清理不匹配的聚合字段(如从"项目任务"改为"部门任务"时置空 projectId 等)
|
|
|
|
|
clearAggregationFields(task.getTempTaskId(), task.getTaskType());
|
|
|
|
|
// Why:领导可能换主执行人,同步更新参与人表保证一致性(AD-13)
|
|
|
|
|
refreshMainAssigneeMember(task.getTempTaskId(), task.getAssigneeId(), task.getAssigneeName());
|
|
|
|
|
|
|
|
|
|
Map<String, Object> variables = buildWorkflowVariables(toBo(task));
|
|
|
|
|
// Why:reassigned 必须基于持久化前后的 old vs new 主执行人 ID 对比计算,
|
|
|
|
|
// 确保流程网关(has_assignee=1 且 reassigned=1 时走"主执行人审阅"节点,否则直入 execute)准确无误。
|
|
|
|
|
variables.put("reassigned", Objects.equals(oldAssigneeId, task.getAssigneeId()) ? "0" : "1");
|
|
|
|
|
variables.put("has_assignee", "1");
|
|
|
|
|
return variables;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 领导审核原子提交:业务字段回写与 Warm-Flow 当前任务办理放在同一分布式事务里完成。
|
|
|
|
|
* 领导审核原子提交:业务字段回写 + Warm-Flow 当前任务办理 放在同一个 Seata 分布式事务中完成。
|
|
|
|
|
* Why:先 buildLeaderReviewVariables(内部已落库业务数据并构建 variables),再将 leaderOpinion 追加入 variables,
|
|
|
|
|
* 最后 completeTask 推进流程。三步一个 @GlobalTransactional,保证"业务数据变更"与"流程推进"的一致性——
|
|
|
|
|
* 不会出现业务数据已更新但流程未推进(卡死),或流程已推进但业务数据未持久化(数据丢失)。
|
|
|
|
|
*/
|
|
|
|
|
@Override
|
|
|
|
|
@GlobalTransactional(rollbackFor = Exception.class)
|
|
|
|
|
@ -753,8 +847,10 @@ public class ErpTempTaskServiceImpl implements IErpTempTaskService {
|
|
|
|
|
if (bo == null) {
|
|
|
|
|
throw new ServiceException("临时任务审批数据不能为空");
|
|
|
|
|
}
|
|
|
|
|
// Why:先校验 taskId 确实属于当前临时任务且 nodeCode=leader_review,防止前端传入篡改的 taskId 越权办理。
|
|
|
|
|
validateWorkflowTaskBelongsToBusiness(bo.getTempTaskId(), bo.getTaskId(), "leader_review", LoginHelper.getUserId());
|
|
|
|
|
Map<String, Object> variables = buildLeaderReviewVariables(bo);
|
|
|
|
|
// Why:leaderOpinion 由 buildLeaderReviewVariables 构建后再追加,确保审批意见同时用于 variables 流转和审批记录 message 展示。
|
|
|
|
|
putIfPresent(variables, "leaderOpinion", bo.getLeaderOpinion());
|
|
|
|
|
|
|
|
|
|
RemoteCompleteTask completeTask = new RemoteCompleteTask();
|
|
|
|
|
@ -762,6 +858,7 @@ public class ErpTempTaskServiceImpl implements IErpTempTaskService {
|
|
|
|
|
completeTask.setMessage(StringUtils.blankToDefault(bo.getLeaderOpinion(), "领导审批"));
|
|
|
|
|
completeTask.setMessageType(Collections.singletonList("1"));
|
|
|
|
|
completeTask.setVariables(variables);
|
|
|
|
|
// Why:completeTask 带 variables 推进当前任务,variables 内的 has_assignee/reassigned 决定下一节点路由(主执行人审阅/直入执行)。
|
|
|
|
|
boolean completed = remoteWorkflowService.completeTask(completeTask);
|
|
|
|
|
if (!completed) {
|
|
|
|
|
throw new ServiceException("领导审批失败,流程未完成流转");
|
|
|
|
|
@ -769,6 +866,13 @@ public class ErpTempTaskServiceImpl implements IErpTempTaskService {
|
|
|
|
|
return Boolean.TRUE;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Why:领导审批时可能修改任务类型(taskType),任务类型决定了归集口径,因此需要 taskType 驱动的条件清空:
|
|
|
|
|
* - taskType='3'(项目任务):必须关联 projectId/projectCode/projectName,同时清空 deptId/deptName(因为项目任务不按部门归集)。
|
|
|
|
|
* - taskType='1'(部门任务):必须关联 deptId/deptName,同时清空 projectId/projectCode/projectName(因为部门任务不按项目归集)。
|
|
|
|
|
* - taskType='2'(公共任务):两者都清空,因为公共任务既不属于具体项目也不属于具体部门。
|
|
|
|
|
* 这样做避免了"项目任务残留部门字段"或"部门任务残留项目字段"的数据不一致问题(AD-01)。
|
|
|
|
|
*/
|
|
|
|
|
private void applyLeaderReviewFields(ErpTempTask task, ErpTempTaskBo bo) {
|
|
|
|
|
task.setTaskTitle(bo.getTaskTitle());
|
|
|
|
|
task.setTaskDesc(bo.getTaskDesc());
|
|
|
|
|
@ -784,14 +888,16 @@ public class ErpTempTaskServiceImpl implements IErpTempTaskService {
|
|
|
|
|
task.setSoftwareLeaderName(bo.getSoftwareLeaderName());
|
|
|
|
|
task.setAssigneeId(bo.getAssigneeId());
|
|
|
|
|
task.setAssigneeName(bo.getAssigneeName());
|
|
|
|
|
task.setCcUserIds(bo.getCcUserIds());
|
|
|
|
|
// task.setCcUserIds(bo.getCcUserIds());
|
|
|
|
|
task.setRemark(bo.getRemark());
|
|
|
|
|
|
|
|
|
|
// Why:taskType='3'(项目任务)时设置项目字段并清空部门字段,反之亦然,防止字段残留导致统计口径错误。
|
|
|
|
|
if ("3".equals(bo.getTaskType())) {
|
|
|
|
|
task.setProjectId(bo.getProjectId());
|
|
|
|
|
task.setProjectCode(bo.getProjectCode());
|
|
|
|
|
task.setProjectName(bo.getProjectName());
|
|
|
|
|
} else {
|
|
|
|
|
// Why:非项目任务必须清空项目字段,避免报表查询按项目筛选时误命中。
|
|
|
|
|
task.setProjectId(null);
|
|
|
|
|
task.setProjectCode(null);
|
|
|
|
|
task.setProjectName(null);
|
|
|
|
|
@ -800,6 +906,7 @@ public class ErpTempTaskServiceImpl implements IErpTempTaskService {
|
|
|
|
|
task.setDeptId(bo.getDeptId());
|
|
|
|
|
task.setDeptName(bo.getDeptName());
|
|
|
|
|
} else {
|
|
|
|
|
// Why:非部门任务必须清空部门字段,避免报表查询按部门筛选时误命中。
|
|
|
|
|
task.setDeptId(null);
|
|
|
|
|
task.setDeptName(null);
|
|
|
|
|
}
|
|
|
|
|
@ -858,7 +965,7 @@ public class ErpTempTaskServiceImpl implements IErpTempTaskService {
|
|
|
|
|
bo.setTotalHours(task.getTotalHours());
|
|
|
|
|
bo.setTaskStatus(task.getTaskStatus());
|
|
|
|
|
bo.setFlowStatus(task.getFlowStatus());
|
|
|
|
|
bo.setCcUserIds(task.getCcUserIds());
|
|
|
|
|
// bo.setCcUserIds(task.getCcUserIds());
|
|
|
|
|
bo.setRemark(task.getRemark());
|
|
|
|
|
bo.setFlowCode(FLOW_CODE);
|
|
|
|
|
bo.setVariables(new HashMap<>(16));
|
|
|
|
|
@ -941,10 +1048,12 @@ public class ErpTempTaskServiceImpl implements IErpTempTaskService {
|
|
|
|
|
if (task == null) {
|
|
|
|
|
throw new ServiceException("临时任务不存在");
|
|
|
|
|
}
|
|
|
|
|
// Why:待领导审核(4)/已关闭(5)禁止改明细,保证整单提交快照不被篡改(AD-15)
|
|
|
|
|
// Why:限制仅 STATUS_RUNNING(3-执行中) 可维护工时。待领导审核(4)/已关闭(5)禁止改明细,
|
|
|
|
|
// 保证整单提交后快照(工时总数+明细)不被后续篡改,领导审核看到的与主执行人提交的完全一致(AD-15)。
|
|
|
|
|
if (!STATUS_RUNNING.equals(task.getTaskStatus())) {
|
|
|
|
|
throw new ServiceException("当前任务非执行中状态,不允许维护工时明细");
|
|
|
|
|
}
|
|
|
|
|
// Why:memberId 从参与人表查询,不以前端传入为准,防止非参与人伪造 memberId 写入工时。
|
|
|
|
|
Long memberId = memberMapper.selectMemberId(bo.getTempTaskId(), LoginHelper.getUserId());
|
|
|
|
|
if (memberId == null) {
|
|
|
|
|
throw new ServiceException("您不是该任务参与人,无法填报工时");
|
|
|
|
|
@ -954,16 +1063,19 @@ public class ErpTempTaskServiceImpl implements IErpTempTaskService {
|
|
|
|
|
}
|
|
|
|
|
bo.setMemberId(memberId);
|
|
|
|
|
bo.setUserId(LoginHelper.getUserId());
|
|
|
|
|
// Why:自然周(周一~周日)是统计归属维度,由工作日期统一推导,保证口径一致(AD-07/AD-08)
|
|
|
|
|
// Why:自然周(周一~周日)是统计归属维度,由工作日期的 DateUtil.beginOfWeek/endOfWeek 统一推导,
|
|
|
|
|
// 保证同一周内所有工时明细的 weekStart/weekEnd 完全一致,避免跨口径统计偏差(AD-07/AD-08)。
|
|
|
|
|
bo.setWeekStart(DateUtil.beginOfWeek(bo.getWorkDate()));
|
|
|
|
|
bo.setWeekEnd(DateUtil.endOfWeek(bo.getWorkDate()));
|
|
|
|
|
ErpTempTaskWorklog entity = MapstructUtils.convert(bo, ErpTempTaskWorklog.class);
|
|
|
|
|
entity.setUpdateBy(LoginHelper.getUserId());
|
|
|
|
|
if (entity.getWorklogId() == null) {
|
|
|
|
|
// Why:新增工时默认 lockFlag='0'(未锁定),待提交完成后统一 lockByTask 设为 '1'。
|
|
|
|
|
entity.setCreateBy(LoginHelper.getUserId());
|
|
|
|
|
entity.setLockFlag("0");
|
|
|
|
|
return worklogMapper.insert(entity) > 0;
|
|
|
|
|
}
|
|
|
|
|
// Why:修改前必须检查 lockFlag,已锁定的工时明细拒绝修改,保护提交完成后的数据完整性。
|
|
|
|
|
ErpTempTaskWorklog old = worklogMapper.selectById(entity.getWorklogId());
|
|
|
|
|
if (old != null && "1".equals(old.getLockFlag())) {
|
|
|
|
|
throw new ServiceException("该工时明细已锁定,不可修改");
|
|
|
|
|
@ -997,7 +1109,10 @@ public class ErpTempTaskServiceImpl implements IErpTempTaskService {
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 主执行人提交完成:聚合校验 + 整单锁定快照(AD-14/AD-15)
|
|
|
|
|
* 主执行人提交完成:聚合工时快照 + 整单锁定 + 提交执行节点(AD-14/AD-15)
|
|
|
|
|
* Why:提交完成的本质是"快照凝固"——totalHours 一次性从所有明细 SUM 计算得出并固化为整单属性,
|
|
|
|
|
* 同时 lockFlag='1' 锁定全部工时明细,确保后续领导审核和评分基于提交时刻的完整快照,不会被新增/修改/删除破坏。
|
|
|
|
|
* 状态从 STATUS_RUNNING(3)→STATUS_PENDING_FINAL(4),表示"暂时冻结待领导确认",不是一个闭环终态。
|
|
|
|
|
*/
|
|
|
|
|
@Override
|
|
|
|
|
@GlobalTransactional(rollbackFor = Exception.class)
|
|
|
|
|
@ -1013,15 +1128,20 @@ public class ErpTempTaskServiceImpl implements IErpTempTaskService {
|
|
|
|
|
if (!STATUS_RUNNING.equals(task.getTaskStatus())) {
|
|
|
|
|
throw new ServiceException("仅执行中任务可提交完成");
|
|
|
|
|
}
|
|
|
|
|
// Why:提交完成前累计总工时必须>0,否则报表统计失去基础(AD-14)
|
|
|
|
|
// Why:提交前必须累计工时>0,至少一条工时明细。空提交会导致报表统计失去数据基础(AD-14)。
|
|
|
|
|
// sumHoursByTask 使用数据库 SUM 聚合,避免应用层循环累加的精度/并发问题。
|
|
|
|
|
BigDecimal total = worklogMapper.sumHoursByTask(tempTaskId);
|
|
|
|
|
if (total == null || total.compareTo(BigDecimal.ZERO) <= 0) {
|
|
|
|
|
throw new ServiceException("提交完成前任务累计总工时必须大于0,且至少有一条工时明细");
|
|
|
|
|
}
|
|
|
|
|
// Why:totalHours 作为提交快照固化到主表,后续评分/报表直接读主表字段,无需再聚合明细(AD-14)。
|
|
|
|
|
task.setTotalHours(total);
|
|
|
|
|
task.setTaskStatus(STATUS_PENDING_FINAL); // 待领导审核(整单临时冻结)
|
|
|
|
|
worklogMapper.lockByTask(tempTaskId, "1"); // 锁定全部明细,待审核期间不可改(AD-15)
|
|
|
|
|
// Why:taskStatus=4(待领导审核) 是提交后的"临时冻结态",整单工时只读不可改(AD-15)。
|
|
|
|
|
task.setTaskStatus(STATUS_PENDING_FINAL);
|
|
|
|
|
// Why:lockFlag='1' 一次性锁定全部工时明细,领导审核期间禁止任何人对工时增删改(AD-15)。
|
|
|
|
|
worklogMapper.lockByTask(tempTaskId, "1");
|
|
|
|
|
baseMapper.updateById(task);
|
|
|
|
|
// Why:completeExecuteTask 在同一 Seata 事务内推进 execute 节点到 leader_final,确保锁与流程推进原子。
|
|
|
|
|
completeExecuteTask(bo, task, total);
|
|
|
|
|
return Boolean.TRUE;
|
|
|
|
|
}
|
|
|
|
|
@ -1049,8 +1169,32 @@ public class ErpTempTaskServiceImpl implements IErpTempTaskService {
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 兜底同步任务状态至"待领导审核"(taskStatus=4)。
|
|
|
|
|
*
|
|
|
|
|
* Why:执行人可能绕过 submitFinish 直接通过工作流通用审批按钮将流程推到 leader_final,
|
|
|
|
|
* 此时 taskStatus 仍为 3(执行中)。本方法在编辑页加载时被前端主动调起,
|
|
|
|
|
* 补齐 submitFinish 缺失的聚合 totalHours + 锁定工时明细 + taskStatus→4。
|
|
|
|
|
*
|
|
|
|
|
* 幂等保证:仅 STATUS_RUNNING(3) 执行同步,已为 STATUS_PENDING_FINAL(4) 或已关闭(5) 的不重复操作。
|
|
|
|
|
*/
|
|
|
|
|
@Override
|
|
|
|
|
public void syncStateToPendingFinal(Long tempTaskId) {
|
|
|
|
|
ErpTempTask task = baseMapper.selectById(tempTaskId);
|
|
|
|
|
if (task == null || !STATUS_RUNNING.equals(task.getTaskStatus())) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
BigDecimal total = worklogMapper.sumHoursByTask(tempTaskId);
|
|
|
|
|
task.setTotalHours(total != null ? total : BigDecimal.ZERO);
|
|
|
|
|
task.setTaskStatus(STATUS_PENDING_FINAL);
|
|
|
|
|
worklogMapper.lockByTask(tempTaskId, "1");
|
|
|
|
|
baseMapper.updateById(task);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 领导评分关闭:对主执行人和每个协作人分别评分,评分即关闭、永久冻结(AD-05/AD-16)
|
|
|
|
|
* Why:评分关闭是整个临时任务生命周期的最终闭环操作——评分落库 + taskStatus→5(已关闭) + 永久 lockFlag='1'。
|
|
|
|
|
* 只有 leader_final 节点的办理人(softwareLeaderId)可以执行此操作。
|
|
|
|
|
*/
|
|
|
|
|
@Override
|
|
|
|
|
@GlobalTransactional(rollbackFor = Exception.class)
|
|
|
|
|
@ -1062,16 +1206,24 @@ public class ErpTempTaskServiceImpl implements IErpTempTaskService {
|
|
|
|
|
if (!Objects.equals(LoginHelper.getUserId(), task.getSoftwareLeaderId())) {
|
|
|
|
|
throw new ServiceException("只有软件部领导可以评分关闭");
|
|
|
|
|
}
|
|
|
|
|
if (!STATUS_PENDING_FINAL.equals(task.getTaskStatus())) {
|
|
|
|
|
throw new ServiceException("仅待领导审核状态可评分关闭");
|
|
|
|
|
// Why:正常情况下 taskStatus 应由 submitFinish 置为 4(待领导审核),但执行人可能绕过 submitFinish
|
|
|
|
|
// 直接通过工作流通用审批按钮将流程推到 leader_final,此时 taskStatus 仍为 3(执行中)。
|
|
|
|
|
// 对于 taskStatus=3 但流程已在 leader_final 的情况,此处兜底补齐 submitFinish 的落库动作。
|
|
|
|
|
if (STATUS_RUNNING.equals(task.getTaskStatus())) {
|
|
|
|
|
BigDecimal total = worklogMapper.sumHoursByTask(bo.getTempTaskId());
|
|
|
|
|
task.setTotalHours(total != null ? total : BigDecimal.ZERO);
|
|
|
|
|
worklogMapper.lockByTask(bo.getTempTaskId(), "1");
|
|
|
|
|
} else if (!STATUS_PENDING_FINAL.equals(task.getTaskStatus())) {
|
|
|
|
|
throw new ServiceException("仅待领导审核或执行中状态可评分关闭");
|
|
|
|
|
}
|
|
|
|
|
// Why:必须对全部参与人(主+协作)都给出评分等级,否则不允许关闭(AD-16)
|
|
|
|
|
// Why:必须对全部参与人(主执行人+协作人)都给出评分等级,不允许遗漏任何人。漏评会导致考核不公(AD-16)。
|
|
|
|
|
List<ErpTempTaskMember> members = memberMapper.selectList(Wrappers.<ErpTempTaskMember>lambdaQuery()
|
|
|
|
|
.eq(ErpTempTaskMember::getTempTaskId, bo.getTempTaskId()));
|
|
|
|
|
validateScoreCoverage(bo, members);
|
|
|
|
|
Date now = new Date();
|
|
|
|
|
Long leaderId = LoginHelper.getUserId();
|
|
|
|
|
// 幂等:清理旧评分再批量写入,避免重复关闭产生多条
|
|
|
|
|
// Why:先 delete 旧评分再批量 insert,实现**幂等关闭**——若领导之前已提交但流程失败,本次重试不会产生重复评分记录。
|
|
|
|
|
// delete+insert 在 @GlobalTransactional 内原子执行,中间不会出现"删了但没插"的不可见窗口。
|
|
|
|
|
scoreMapper.delete(Wrappers.<ErpTempTaskScore>lambdaQuery()
|
|
|
|
|
.eq(ErpTempTaskScore::getTempTaskId, bo.getTempTaskId()));
|
|
|
|
|
for (ErpTempTaskScoreBo s : bo.getScoreList()) {
|
|
|
|
|
@ -1084,10 +1236,14 @@ public class ErpTempTaskServiceImpl implements IErpTempTaskService {
|
|
|
|
|
score.setScoreTime(now);
|
|
|
|
|
scoreMapper.insert(score);
|
|
|
|
|
}
|
|
|
|
|
task.setActualFinishTime(now); // 实际关闭时间=评分时间(提前/按期/延期口径,AD-06)
|
|
|
|
|
// Why:actualFinishTime = 评分时间,作为提前/按期/延期的口径基准(AD-06)。
|
|
|
|
|
task.setActualFinishTime(now);
|
|
|
|
|
task.setTaskStatus(STATUS_CLOSED);
|
|
|
|
|
worklogMapper.lockByTask(bo.getTempTaskId(), "1"); // 永久锁定全部工时明细(AD-04)
|
|
|
|
|
// Why:永久锁定全部工时明细 lockFlag='1',关闭后任何人均不可修改工时(AD-04)。
|
|
|
|
|
worklogMapper.lockByTask(bo.getTempTaskId(), "1");
|
|
|
|
|
baseMapper.updateById(task);
|
|
|
|
|
// Why:completeLeaderFinalTask 在同一事务内完成 leader_final 节点,流程随之结束(FINISH),
|
|
|
|
|
// processHandler 收到 FINISH 事件后会兜底确认 taskStatus 已置为 5。
|
|
|
|
|
completeLeaderFinalTask(bo);
|
|
|
|
|
return Boolean.TRUE;
|
|
|
|
|
}
|
|
|
|
|
@ -1143,11 +1299,23 @@ public class ErpTempTaskServiceImpl implements IErpTempTaskService {
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Why:校验前端传入的 taskId 确实属于当前临时任务、处于预期节点、且当前用户是办理人。
|
|
|
|
|
* 通过 Warm-Flow 的 flowHisTaskList 查询给定 businessId 下的所有流程任务历史列表,
|
|
|
|
|
* 然后**线性搜索**匹配 taskId,找到后校验 nodeCode 和 approver 是否与预期一致。
|
|
|
|
|
* 线性搜索而非 Map 的原因是:流程任务列表通常较短(10条以内),O(n) 足够且代码更清晰直接。
|
|
|
|
|
* 三重校验:
|
|
|
|
|
* 1. taskId 必须存在于该临时任务的历史任务列表中(防止跨业务窜用 taskId)
|
|
|
|
|
* 2. nodeCode 必须匹配预期节点(防止在错误的审批节点办理,如本该 leader_review 却在 execute 节点调用)
|
|
|
|
|
* 3. 当前登录人必须在 approver 列表中(防止越权代办)
|
|
|
|
|
* 任意一项不匹配均抛 ServiceException,在 @GlobalTransactional 外层触发 Seata 回滚。
|
|
|
|
|
*/
|
|
|
|
|
@SuppressWarnings("unchecked")
|
|
|
|
|
private void validateWorkflowTaskBelongsToBusiness(Long tempTaskId, Long taskId, String expectedNodeCode, Long expectedUserId) {
|
|
|
|
|
if (tempTaskId == null || taskId == null) {
|
|
|
|
|
throw new ServiceException("临时任务ID和流程任务ID不能为空");
|
|
|
|
|
}
|
|
|
|
|
// Why:flowHisTaskList 返回该业务 ID 下的所有流程任务记录(含历史),从中定位当前 taskId 并校验。
|
|
|
|
|
Map<String, Object> flowInfo = remoteWorkflowService.flowHisTaskList(String.valueOf(tempTaskId));
|
|
|
|
|
Object listObj = flowInfo == null ? null : flowInfo.get("list");
|
|
|
|
|
if (!(listObj instanceof Collection<?> taskList)) {
|
|
|
|
|
@ -1155,6 +1323,7 @@ public class ErpTempTaskServiceImpl implements IErpTempTaskService {
|
|
|
|
|
}
|
|
|
|
|
String taskIdText = Convert.toStr(taskId);
|
|
|
|
|
String expectedUserIdText = Convert.toStr(expectedUserId);
|
|
|
|
|
// Why:线性遍历查找匹配的 taskId,找到后立即校验并 return;未找到则抛异常。
|
|
|
|
|
for (Object item : taskList) {
|
|
|
|
|
if (!(item instanceof Map<?, ?> rawTask)) {
|
|
|
|
|
continue;
|
|
|
|
|
@ -1163,10 +1332,14 @@ public class ErpTempTaskServiceImpl implements IErpTempTaskService {
|
|
|
|
|
if (!Objects.equals(taskIdText, Convert.toStr(taskMap.get("id")))) {
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
// Why:nodeCode 精确匹配——例如 scoreAndClose 要求 nodeCode="leader_final",
|
|
|
|
|
// submitFinish 要求 nodeCode="execute",防止在错误节点调用业务接口。
|
|
|
|
|
if (StringUtils.isNotBlank(expectedNodeCode)
|
|
|
|
|
&& !Objects.equals(expectedNodeCode, Convert.toStr(taskMap.get("nodeCode")))) {
|
|
|
|
|
throw new ServiceException("流程任务节点与当前业务操作不匹配");
|
|
|
|
|
}
|
|
|
|
|
// Why:approver 是逗号分隔的办理人 ID 列表(如 "101,203"),需要解析后检查当前用户是否在其中。
|
|
|
|
|
// 支持会签/或签场景同时传入多个办理人的情况。
|
|
|
|
|
String approver = Convert.toStr(taskMap.get("approver"));
|
|
|
|
|
if (StringUtils.isNotBlank(approver) && StringUtils.isNotBlank(expectedUserIdText)) {
|
|
|
|
|
List<String> approvers = Arrays.stream(approver.split(","))
|
|
|
|
|
@ -1176,8 +1349,10 @@ public class ErpTempTaskServiceImpl implements IErpTempTaskService {
|
|
|
|
|
throw new ServiceException("当前用户不是该流程任务办理人");
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return;
|
|
|
|
|
return; // 所有校验通过,找到匹配的流程任务
|
|
|
|
|
}
|
|
|
|
|
// Why:遍历完未找到匹配 taskId,说明前端传入的 taskId 不属于当前临时任务,
|
|
|
|
|
// 可能是跨业务窜用或者已过期的待办,直接拒绝。
|
|
|
|
|
throw new ServiceException("流程任务不属于当前临时任务");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|