gaoluyang
5 天以前 321564177399931273e63647f37cdddd268fa5a5
fix: 修复全流程测试 P0/P1 问题

- 考勤汇总 yearMonth 加反引号,规避 MySQL 保留字 YEAR_MONTH 导致的 SQL 语法错误
- 报工业务接口对已发起审批流程的单据拦截,改由待办任务处理;BPM 回调走内部方法
- 发起人节点保留发起人候选人,避免发起人账号被禁用后流程卡在发起人节点
- 批量排班提示占位符 %s 改为 {},恢复参数替换
- 发货通知提交拆分「流程 Key 不存在」与「流程不属于发货审批分类」两类提示

Co-Authored-By: Claude Code <noreply@anthropic.com>
已修改7个文件
116 ■■■■■ 文件已修改
yudao-module-bpm/src/main/java/cn/iocoder/yudao/module/bpm/framework/flowable/core/candidate/BpmTaskCandidateInvoker.java 40 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-bpm/src/test/java/cn/iocoder/yudao/module/bpm/framework/flowable/core/candidate/BpmTaskCandidateInvokerTest.java 19 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-hrm-api/src/main/java/cn/iocoder/yudao/module/hrm/enums/ErrorCodeConstants.java 6 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-hrm/src/main/java/cn/iocoder/yudao/module/hrm/dal/dataobject/attendance/HrmAttendanceSummaryDO.java 4 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/enums/ErrorCodeConstants.java 6 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/service/pro/feedback/MesProFeedbackServiceImpl.java 29 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/service/wm/salesnotice/MesWmSalesNoticeServiceImpl.java 12 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-bpm/src/main/java/cn/iocoder/yudao/module/bpm/framework/flowable/core/candidate/BpmTaskCandidateInvoker.java
@@ -26,6 +26,7 @@
import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception;
import static cn.iocoder.yudao.module.bpm.enums.ErrorCodeConstants.MODEL_DEPLOY_FAIL_TASK_CANDIDATE_NOT_CONFIG;
import static cn.iocoder.yudao.module.bpm.framework.flowable.core.enums.BpmnModelConstants.START_USER_NODE_ID;
/**
 * {@link BpmTaskCandidateStrategy} 的调用者,用于调用对应的策略,实现任务的候选人的计算
@@ -106,8 +107,13 @@
            Integer strategy = BpmnModelUtils.parseCandidateStrategy(flowElement);
            String param = BpmnModelUtils.parseCandidateParam(flowElement);
            Set<Long> userIds = getCandidateStrategy(strategy).calculateUsersByTask(execution, param);
            // 1.2 移除被禁用的用户
            removeDisableUsers(userIds);
            ProcessInstance processInstance = SpringUtil.getBean(BpmProcessInstanceService.class)
                    .getProcessInstance(execution.getProcessInstanceId());
            Assert.notNull(processInstance, "流程实例({}) 不存在", execution.getProcessInstanceId());
            Long startUserId = Long.valueOf(processInstance.getStartUserId());
            // 1.2 移除被禁用的用户;发起人节点必须保留发起人,
            //     否则发起人账号被禁用时该节点无人可审,流程会一直停在「发起人」节点
            removeDisableUsers(userIds, isStartUserNode(flowElement) ? startUserId : null);
            // 2. 候选人为空时,根据“审批人为空”的配置补充
            if (CollUtil.isEmpty(userIds)) {
@@ -117,10 +123,7 @@
            }
            // 3. 移除发起人的用户
            ProcessInstance processInstance = SpringUtil.getBean(BpmProcessInstanceService.class)
                    .getProcessInstance(execution.getProcessInstanceId());
            Assert.notNull(processInstance, "流程实例({}) 不存在", execution.getProcessInstanceId());
            removeStartUserIfSkip(userIds, flowElement, Long.valueOf(processInstance.getStartUserId()));
            removeStartUserIfSkip(userIds, flowElement, startUserId);
            return userIds;
        });
    }
@@ -146,8 +149,8 @@
        String param = BpmnModelUtils.parseCandidateParam(flowElement);
        Set<Long> userIds = getCandidateStrategy(strategy).calculateUsersByActivity(bpmnModel, activityId, param,
                startUserId, processDefinitionId, processVariables);
        // 1.2 移除被禁用的用户
        removeDisableUsers(userIds);
        // 1.2 移除被禁用的用户;发起人节点必须保留发起人,避免发起人禁用后流程停在「发起人」节点
        removeDisableUsers(userIds, START_USER_NODE_ID.equals(activityId) ? startUserId : null);
        // 2. 候选人为空时,根据“审批人为空”的配置补充
        if (CollUtil.isEmpty(userIds)) {
@@ -163,17 +166,38 @@
    @VisibleForTesting
    void removeDisableUsers(Set<Long> assigneeUserIds) {
        removeDisableUsers(assigneeUserIds, null);
    }
    /**
     * 移除被禁用的用户
     *
     * @param assigneeUserIds 候选人
     * @param keepUserId      需要保留的用户(发起人节点保留发起人),可为空
     */
    @VisibleForTesting
    void removeDisableUsers(Set<Long> assigneeUserIds, Long keepUserId) {
        if (CollUtil.isEmpty(assigneeUserIds)) {
            return;
        }
        Map<Long, AdminUserRespDTO> userMap = adminUserApi.getUserMap(assigneeUserIds);
        assigneeUserIds.removeIf(id -> {
            if (ObjectUtil.equal(id, keepUserId)) {
                return false;
            }
            AdminUserRespDTO user = userMap.get(id);
            return user == null || CommonStatusEnum.isDisable(user.getStatus());
        });
    }
    /**
     * 是否为「发起人」节点
     */
    private static boolean isStartUserNode(FlowElement flowElement) {
        return flowElement != null && START_USER_NODE_ID.equals(flowElement.getId());
    }
    /**
     * 如果“审批人与发起人相同时”,配置了 SKIP 跳过,则移除发起人
     *
     * 注意:如果只有一个候选人,则不处理,避免无法审批
yudao-module-bpm/src/test/java/cn/iocoder/yudao/module/bpm/framework/flowable/core/candidate/BpmTaskCandidateInvokerTest.java
@@ -272,4 +272,23 @@
        assertEquals(asSet(1L), assigneeUserIds);
    }
    @Test
    public void testRemoveDisableUsers_keepDisabledUser() {
        // 准备参数:2L 被禁用,但它是发起人节点需要保留的发起人;3L 找不到
        Set<Long> assigneeUserIds = asSet(1L, 2L, 3L);
        // mock 方法
        AdminUserRespDTO user1 = randomPojo(AdminUserRespDTO.class, o -> o.setId(1L)
                .setStatus(CommonStatusEnum.ENABLE.getStatus()));
        AdminUserRespDTO user2 = randomPojo(AdminUserRespDTO.class, o -> o.setId(2L)
                .setStatus(CommonStatusEnum.DISABLE.getStatus()));
        Map<Long, AdminUserRespDTO> userMap = MapUtil.builder(user1.getId(), user1)
                .put(user2.getId(), user2).build();
        when(adminUserApi.getUserMap(eq(assigneeUserIds))).thenReturn(userMap);
        // 调用
        taskCandidateInvoker.removeDisableUsers(assigneeUserIds, 2L);
        // 断言:保留被禁用的发起人 2L,移除不存在的 3L
        assertEquals(asSet(1L, 2L), assigneeUserIds);
    }
}
yudao-module-hrm-api/src/main/java/cn/iocoder/yudao/module/hrm/enums/ErrorCodeConstants.java
@@ -174,8 +174,8 @@
    ErrorCode SCHEDULE_ITEM_LIST_EMPTY = new ErrorCode(1_030_010_000, "排班数据不能为空");
    ErrorCode SCHEDULE_SHIFT_NOT_EXISTS = new ErrorCode(1_030_010_001, "排班引用的班次不存在");
    ErrorCode SCHEDULE_EMPLOYEE_NOT_EXISTS = new ErrorCode(1_030_010_002, "排班引用的员工不存在");
    ErrorCode SCHEDULE_BATCH_DATE_RANGE_INVALID = new ErrorCode(1_030_010_003, "批量排班日期区间不合法:结束日期[%s]早于开始日期[%s],请调整后重试");
    ErrorCode SCHEDULE_BATCH_RANGE_TOO_LONG = new ErrorCode(1_030_010_004, "批量排班日期区间为[%s ~ %s],共 %s 天,超过单次最多 62 天的限制,请分段排班");
    ErrorCode SCHEDULE_BATCH_NO_EMPLOYEE = new ErrorCode(1_030_010_005, "部门[%s](含下级部门)下没有符合条件的员工(岗位:%s,员工状态:%s),未生成任何排班,请调整筛选条件后重试");
    ErrorCode SCHEDULE_BATCH_DATE_RANGE_INVALID = new ErrorCode(1_030_010_003, "批量排班日期区间不合法:结束日期[{}]早于开始日期[{}],请调整后重试");
    ErrorCode SCHEDULE_BATCH_RANGE_TOO_LONG = new ErrorCode(1_030_010_004, "批量排班日期区间为[{} ~ {}],共 {} 天,超过单次最多 62 天的限制,请分段排班");
    ErrorCode SCHEDULE_BATCH_NO_EMPLOYEE = new ErrorCode(1_030_010_005, "部门[{}](含下级部门)下没有符合条件的员工(岗位:{},员工状态:{}),未生成任何排班,请调整筛选条件后重试");
}
yudao-module-hrm/src/main/java/cn/iocoder/yudao/module/hrm/dal/dataobject/attendance/HrmAttendanceSummaryDO.java
@@ -2,6 +2,7 @@
import cn.iocoder.yudao.framework.mybatis.core.dataobject.BaseDO;
import com.baomidou.mybatisplus.annotation.KeySequence;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.*;
@@ -43,7 +44,10 @@
    /**
     * 年月,格式:yyyy-MM
     *
     * 注意:YEAR_MONTH 是 MySQL 保留字,字段名必须加反引号,否则生成的 SQL 会报语法错误
     */
    @TableField("`year_month`")
    private String yearMonth;
    /**
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/enums/ErrorCodeConstants.java
@@ -358,6 +358,8 @@
            "工单已开启报工审批,但未配置审批流程,请先为工单配置流程后再提交报工");
    ErrorCode PRO_FEEDBACK_APPROVAL_PROCESS_KEY_INVALID = new ErrorCode(1_040_506_022,
            "工单配置的报工审批流程未激活或不属于报工审批分类,请检查工单审批配置");
    ErrorCode PRO_FEEDBACK_APPROVAL_HANDLE_IN_BPM = new ErrorCode(1_040_506_023,
            "该报工单已发起审批流程,不能在报工页面直接通过或拒绝;请审批人前往「待办任务」处理该流程,流程结束后报工状态会自动更新");
    // ========== MES 生产管理-辅助工时配置(1-040-510-000) ==========
    ErrorCode PRO_WORKTIME_AUX_NOT_EXISTS = new ErrorCode(1_040_510_000, "辅助工时项目不存在");
@@ -858,6 +860,10 @@
    ErrorCode WM_SALES_NOTICE_ALREADY_EXISTS = new ErrorCode(1_040_720_015, "该销售订单已生成发货通知单,不能重复生成");
    ErrorCode WM_SALES_NOTICE_SUBMIT_PROCESS_KEY_REQUIRED = new ErrorCode(1_040_720_020, "提交发货审批前必须选择审批流程:未传流程定义 Key");
    ErrorCode WM_SALES_NOTICE_BPM_PROCESS_DEFINITION_NOT_EXISTS = new ErrorCode(1_040_720_021, "发货审批流程未配置:请先在「流程管理」中设计并启用「发货审批」流程,再提交发货单");
    ErrorCode WM_SALES_NOTICE_BPM_PROCESS_DEFINITION_NOT_IN_CATEGORY = new ErrorCode(1_040_720_024,
            "所选审批流程[{}]不属于「发货审批」分类,不能用于发货单审批;请在提交时选择发货审批分类下的流程");
    ErrorCode WM_SALES_NOTICE_BPM_PROCESS_KEY_INVALID = new ErrorCode(1_040_720_025,
            "所选审批流程 Key[{}] 不存在或已停用,请重新选择「发货审批」分类下已启用的流程");
    ErrorCode WM_SALES_NOTICE_UPDATE_AUDIT_STATUS_FAIL_NOT_APPROVING = new ErrorCode(1_040_720_022, "发货单({})当前状态为[{}],不处于审批中,无法更新审批结果");
    ErrorCode WM_SALES_NOTICE_STATUS_NOT_ALLOW_SUBMIT = new ErrorCode(1_040_720_023, "只有草稿状态的发货通知单才能提交审批");
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/service/pro/feedback/MesProFeedbackServiceImpl.java
@@ -352,21 +352,29 @@
                 reqVO.setLotNumber(reason);
               }
            }
            approveFeedback(reqVO);
            // BPM 回调时流程实例仍在运行,必须走内部方法,不能被「已发起流程」的校验拦住
            doApproveFeedback(reqVO, feedback, true);
        } else if (cn.iocoder.yudao.module.bpm.enums.task.BpmProcessInstanceStatusEnum.REJECT.getStatus().equals(bpmResult) ||
                   cn.iocoder.yudao.module.bpm.enums.task.BpmProcessInstanceStatusEnum.CANCEL.getStatus().equals(bpmResult)) {
            rejectFeedback(id);
            doRejectFeedback(id);
        }
    }
    @Override
    @Transactional(rollbackFor = Exception.class)
    public void rejectFeedback(Long id) {
        equipmentService.lock();
        // 1. 校验存在 + 审批中状态
        validateFeedbackStatusApproving(id);
        MesProFeedbackDO feedback = validateFeedbackStatusApproving(id);
        // 1.1 已发起审批流程的报工单只能由审批人通过「待办任务」处理,避免业务接口绕过流程
        if (StrUtil.isNotBlank(feedback.getProcessInstanceId())) {
            throw exception(PRO_FEEDBACK_APPROVAL_HANDLE_IN_BPM);
        }
        doRejectFeedback(id);
    }
        // 2. 更新状态为草稿
    private void doRejectFeedback(Long id) {
        equipmentService.lock();
        // 1. 更新状态为草稿
        feedbackMapper.updateById(new MesProFeedbackDO().setId(id)
                .setStatus(MesProFeedbackStatusEnum.PREPARE.getStatus()));
    }
@@ -378,9 +386,18 @@
    }
    private boolean approveFeedback(MesProFeedbackApproveReqVO reqVO, boolean enforceQualityGate) {
        equipmentService.lock();
        // 1.1 校验存在 + 审批中状态
        MesProFeedbackDO feedback = validateFeedbackStatusApproving(reqVO.getId());
        // 1.2 已发起审批流程的报工单只能由审批人通过「待办任务」处理,避免业务接口绕过流程
        if (StrUtil.isNotBlank(feedback.getProcessInstanceId())) {
            throw exception(PRO_FEEDBACK_APPROVAL_HANDLE_IN_BPM);
        }
        return doApproveFeedback(reqVO, feedback, enforceQualityGate);
    }
    private boolean doApproveFeedback(MesProFeedbackApproveReqVO reqVO, MesProFeedbackDO feedback,
                                      boolean enforceQualityGate) {
        equipmentService.lock();
        equipmentService.validateApproval(feedback);
        try (var equipmentScope = equipmentService.beginApproval(feedback)) {
            return approveValidatedFeedback(reqVO, feedback, enforceQualityGate);
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/service/wm/salesnotice/MesWmSalesNoticeServiceImpl.java
@@ -167,10 +167,16 @@
        }
        validateSalesNoticeApproveCategoryAndProcessDefinition();
        ProcessDefinition processDefinition = bpmProcessDefinitionService.getActiveProcessDefinition(processDefinitionKey);
        if (processDefinition == null || bpmProcessDefinitionService
        // 区分「Key 不存在/已停用」与「流程存在但不属于发货审批分类」,分别给出可执行的提示
        if (processDefinition == null) {
            throw exception(WM_SALES_NOTICE_BPM_PROCESS_KEY_INVALID, processDefinitionKey);
        }
        boolean inDeliveryCategory = bpmProcessDefinitionService
                .getProcessDefinitionInfoListByCategory(SALES_DELIVERY_APPROVE_CATEGORY_CODE).stream()
                .noneMatch(info -> Objects.equals(info.getProcessDefinitionId(), processDefinition.getId()))) {
            throw exception(WM_SALES_NOTICE_BPM_PROCESS_DEFINITION_NOT_EXISTS);
                .anyMatch(info -> Objects.equals(info.getProcessDefinitionId(), processDefinition.getId()));
        if (!inDeliveryCategory) {
            throw exception(WM_SALES_NOTICE_BPM_PROCESS_DEFINITION_NOT_IN_CATEGORY,
                    StrUtil.blankToDefault(processDefinition.getName(), processDefinitionKey));
        }
        // 4. 创建审批流程实例,进入审批中