fix: 修复全流程测试 P0/P1 问题
- 考勤汇总 yearMonth 加反引号,规避 MySQL 保留字 YEAR_MONTH 导致的 SQL 语法错误
- 报工业务接口对已发起审批流程的单据拦截,改由待办任务处理;BPM 回调走内部方法
- 发起人节点保留发起人候选人,避免发起人账号被禁用后流程卡在发起人节点
- 批量排班提示占位符 %s 改为 {},恢复参数替换
- 发货通知提交拆分「流程 Key 不存在」与「流程不属于发货审批分类」两类提示
Co-Authored-By: Claude Code <noreply@anthropic.com>
| | |
| | | |
| | | 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} 的调用者,用于调用对应的策略,实现任务的候选人的计算 |
| | |
| | | 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)) { |
| | |
| | | } |
| | | |
| | | // 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; |
| | | }); |
| | | } |
| | |
| | | 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)) { |
| | |
| | | |
| | | @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 跳过,则移除发起人 |
| | | * |
| | | * 注意:如果只有一个候选人,则不处理,避免无法审批 |
| | |
| | | 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); |
| | | } |
| | | |
| | | } |
| | |
| | | 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, "部门[{}](含下级部门)下没有符合条件的员工(岗位:{},员工状态:{}),未生成任何排班,请调整筛选条件后重试"); |
| | | |
| | | } |
| | |
| | | |
| | | 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.*; |
| | |
| | | |
| | | /** |
| | | * 年月,格式:yyyy-MM |
| | | * |
| | | * 注意:YEAR_MONTH 是 MySQL 保留字,字段名必须加反引号,否则生成的 SQL 会报语法错误 |
| | | */ |
| | | @TableField("`year_month`") |
| | | private String yearMonth; |
| | | |
| | | /** |
| | |
| | | "工单已开启报工审批,但未配置审批流程,请先为工单配置流程后再提交报工"); |
| | | 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, "辅助工时项目不存在"); |
| | |
| | | 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, "只有草稿状态的发货通知单才能提交审批"); |
| | | |
| | |
| | | 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())); |
| | | } |
| | |
| | | } |
| | | |
| | | 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); |
| | |
| | | } |
| | | 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. 创建审批流程实例,进入审批中 |