2 天以前 111270df037596a04df97f787ca8b9199dd99866
feat(crm): 增加商机客户团队权限校验功能

- 在 CrmPermissionAspect 中新增 CrmBusinessDO 和 CrmBusinessMapper 依赖
- 修改 validatePermission 方法增加 bizId 参数用于权限校验
- 修复注释中的错别字"自的身"为"自身的"
- 新增商机特殊处理逻辑:检查客户团队权限
- 在 validateBusinessCustomerPermission 方法中实现商机客户团队权限验证
- 在 CrmPermissionUtils 中增加商机权限条件的特殊处理
- 通过子查询方式关联商机团队和客户团队权限

feat(mdm): 增加物料批量删除功能

- 在 MdmItemController 中新增 deleteItemList 接口支持批量删除
- 在 MdmItemService 中定义 deleteItemList 方法接口
- 在 MdmItemServiceImpl 中实现批量删除逻辑,逐个调用单条删除保持校验

feat(mes): 优化生产报工完成流程

- 调整报工完成步骤顺序,先更新报工状态为已完成
- 非关键工序也需检查 MPS 完成状态
- 在更新工单生产数量后同步更新 MPS 已生产数量
- 新增 checkAndFinishMps 方法检查并自动完成 MPS
- 增加日志记录 MPS 完成检查过程
已修改10个文件
233 ■■■■■ 文件已修改
yudao-module-crm/src/main/java/cn/iocoder/yudao/module/crm/framework/permission/core/aop/CrmPermissionAspect.java 58 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-crm/src/main/java/cn/iocoder/yudao/module/crm/util/CrmPermissionUtils.java 38 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mdm/src/main/java/cn/iocoder/yudao/module/mdm/controller/admin/item/MdmItemController.java 9 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mdm/src/main/java/cn/iocoder/yudao/module/mdm/service/item/MdmItemService.java 7 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mdm/src/main/java/cn/iocoder/yudao/module/mdm/service/item/MdmItemServiceImpl.java 10 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes-api/src/main/java/cn/iocoder/yudao/module/mes/api/mps/MesProMpsApi.java 20 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/api/mps/MesProMpsApiImpl.java 11 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/service/pro/feedback/MesProFeedbackServiceImpl.java 32 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/service/pro/mps/MesProMpsService.java 11 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/service/pro/mps/MesProMpsServiceImpl.java 37 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-crm/src/main/java/cn/iocoder/yudao/module/crm/framework/permission/core/aop/CrmPermissionAspect.java
@@ -5,7 +5,9 @@
import cn.hutool.core.util.StrUtil;
import cn.iocoder.yudao.framework.common.util.spring.SpringExpressionUtils;
import cn.iocoder.yudao.framework.web.core.util.WebFrameworkUtils;
import cn.iocoder.yudao.module.crm.dal.dataobject.business.CrmBusinessDO;
import cn.iocoder.yudao.module.crm.dal.dataobject.permission.CrmPermissionDO;
import cn.iocoder.yudao.module.crm.dal.mysql.business.CrmBusinessMapper;
import cn.iocoder.yudao.module.crm.enums.common.CrmBizTypeEnum;
import cn.iocoder.yudao.module.crm.enums.permission.CrmPermissionLevelEnum;
import cn.iocoder.yudao.module.crm.framework.permission.core.annotations.CrmPermission;
@@ -43,6 +45,9 @@
    @Resource
    private AdminUserApi adminUserApi;
    @Resource
    private CrmBusinessMapper businessMapper;
    @Before("@annotation(crmPermission)")
    public void doBefore(JoinPoint joinPoint, CrmPermission crmPermission) {
        // 1.1 获取相关属性值
@@ -62,10 +67,10 @@
        // 2. 逐个校验权限
        List<CrmPermissionDO> permissionList = crmPermissionService.getPermissionListByBiz(bizType, bizIds);
        Map<Long, List<CrmPermissionDO>> multiMap = convertMultiMap(permissionList, CrmPermissionDO::getBizId);
        bizIds.forEach(bizId -> validatePermission(bizType, multiMap.get(bizId), permissionLevel));
        bizIds.forEach(bizId -> validatePermission(bizType, bizId, multiMap.get(bizId), permissionLevel));
    }
    private void validatePermission(Integer bizType, List<CrmPermissionDO> bizPermissions, Integer permissionLevel) {
    private void validatePermission(Integer bizType, Long bizId, List<CrmPermissionDO> bizPermissions, Integer permissionLevel) {
        // 1. 如果是超级管理员则直接通过
        if (CrmPermissionUtils.isCrmAdmin()) {
            return;
@@ -85,7 +90,7 @@
            }
        }
        // 2. 只考虑自的身权限
        // 2. 只考虑自身的权限
        Long userId = getUserId();
        CrmPermissionDO userPermission = CollUtil.findOne(bizPermissions, permission -> ObjUtil.equal(permission.getUserId(), userId));
        if (userPermission != null) {
@@ -94,7 +99,14 @@
            }
        }
        // 3. 考虑下级的权限
        // 3. 商机特殊处理:检查客户团队权限
        if (CrmBizTypeEnum.CRM_BUSINESS.getType().equals(bizType)) {
            if (validateBusinessCustomerPermission(bizId, userId, permissionLevel)) {
                return;
            }
        }
        // 4. 考虑下级的权限
        List<AdminUserRespDTO> subordinateUserIds = adminUserApi.getUserListBySubordinate(userId);
        for (Long subordinateUserId : convertSet(subordinateUserIds, AdminUserRespDTO::getId)) {
            CrmPermissionDO subordinatePermission = CollUtil.findOne(bizPermissions,
@@ -102,15 +114,51 @@
            if (subordinatePermission != null && isUserPermissionValid(subordinatePermission, permissionLevel)) {
                return;
            }
            // 商机特殊处理:检查客户团队权限(下属的)
            if (CrmBizTypeEnum.CRM_BUSINESS.getType().equals(bizType)) {
                if (validateBusinessCustomerPermission(bizId, subordinateUserId, permissionLevel)) {
                    return;
                }
            }
        }
        // 4. 没有权限,抛出异常
        // 5. 没有权限,抛出异常
        log.info("[doBefore][userId({}) 要求权限({}) 实际权限({}) 数据校验错误]", // 打个 info 日志,方便后续排查问题、审计
                userId, permissionLevel, toJsonString(userPermission));
        throw exception(CRM_PERMISSION_DENIED, CrmBizTypeEnum.getNameByType(bizType));
    }
    /**
     * 商机特殊权限校验:检查用户是否有该商机所属客户的团队权限
     *
     * @param businessId     商机编号
     * @param userId         用户编号
     * @param permissionLevel 需要的权限级别
     * @return 是否有权限
     */
    private boolean validateBusinessCustomerPermission(Long businessId, Long userId, Integer permissionLevel) {
        // 获取商机的客户ID
        CrmBusinessDO business = businessMapper.selectById(businessId);
        if (business == null || business.getCustomerId() == null) {
            return false;
        }
        // 检查客户团队权限
        List<CrmPermissionDO> customerPermissions = crmPermissionService.getPermissionListByBiz(
                CrmBizTypeEnum.CRM_CUSTOMER.getType(), Collections.singleton(business.getCustomerId()));
        if (CollUtil.isEmpty(customerPermissions)) {
            // 客户公海数据,READ 权限放行
            return CrmPermissionLevelEnum.isRead(permissionLevel);
        }
        // 检查用户是否有客户团队的权限
        CrmPermissionDO userCustomerPermission = CollUtil.findOne(customerPermissions,
                permission -> ObjUtil.equal(permission.getUserId(), userId));
        if (userCustomerPermission != null && isUserPermissionValid(userCustomerPermission, permissionLevel)) {
            return true;
        }
        return false;
    }
    /**
     * 校验用户权限是否有效
     *
     * @param userPermission   用户拥有的权限
yudao-module-crm/src/main/java/cn/iocoder/yudao/module/crm/util/CrmPermissionUtils.java
@@ -58,11 +58,16 @@
            if (CrmPermissionUtils.isCrmAdmin()) { // 特殊逻辑:如果是超管,直接查询所有,不过滤数据权限
                return;
            }
            query.innerJoin(CrmPermissionDO.class, on -> on.eq(CrmPermissionDO::getBizType, bizType)
                    .eq(CrmPermissionDO::getBizId, bizId)
                    .in(CrmPermissionDO::getLevel, CrmPermissionLevelEnum.READ.getLevel(), CrmPermissionLevelEnum.WRITE.getLevel())
                    .eq(CrmPermissionDO::getUserId,userId));
            query.ne(ownerUserIdField, userId);
            // 商机特殊处理:同时关联商机团队和客户团队的权限
            if (CrmBizTypeEnum.CRM_BUSINESS.getType().equals(bizType)) {
                appendBusinessPermissionCondition(query, bizId, userId, ownerUserIdField);
            } else {
                query.innerJoin(CrmPermissionDO.class, on -> on.eq(CrmPermissionDO::getBizType, bizType)
                        .eq(CrmPermissionDO::getBizId, bizId)
                        .in(CrmPermissionDO::getLevel, CrmPermissionLevelEnum.READ.getLevel(), CrmPermissionLevelEnum.WRITE.getLevel())
                        .eq(CrmPermissionDO::getUserId, userId));
                query.ne(ownerUserIdField, userId);
            }
        }
        // 场景三:下属负责的数据(下属是负责人)
        if (CrmSceneTypeEnum.isSubordinate(sceneType)) {
@@ -76,4 +81,27 @@
        }
    }
    /**
     * 商机特殊权限条件:同时关联商机团队和客户团队的权限
     * 商机继承客户团队的权限,即客户团队成员也能看到该客户下的商机
     */
    private static <T extends MPJLambdaWrapper<?>, S> void appendBusinessPermissionCondition(T query, SFunction<S, ?> bizId,
                                                                                              Long userId, String ownerUserIdField) {
        // 使用子查询方式:商机团队成员 OR 客户团队成员
        // 子查询1:商机团队成员权限
        String subQuery1 = "(SELECT p1.biz_id FROM crm_permission p1 " +
                "WHERE p1.biz_type = " + CrmBizTypeEnum.CRM_BUSINESS.getType() + " " +
                "AND p1.level IN (" + CrmPermissionLevelEnum.READ.getLevel() + "," + CrmPermissionLevelEnum.WRITE.getLevel() + ") " +
                "AND p1.user_id = " + userId + ")";
        // 子查询2:客户团队成员权限(通过商机的 customer_id 关联)
        String subQuery2 = "(SELECT b.id FROM crm_business b " +
                "INNER JOIN crm_permission p2 ON p2.biz_type = " + CrmBizTypeEnum.CRM_CUSTOMER.getType() + " " +
                "AND p2.biz_id = b.customer_id " +
                "AND p2.level IN (" + CrmPermissionLevelEnum.READ.getLevel() + "," + CrmPermissionLevelEnum.WRITE.getLevel() + ") " +
                "AND p2.user_id = " + userId + ")";
        // 使用 OR 连接两个子查询,并排除自己是负责人的情况
        query.apply("t.id IN (" + subQuery1 + " UNION " + subQuery2 + ")")
             .ne(ownerUserIdField, userId);
    }
}
yudao-module-mdm/src/main/java/cn/iocoder/yudao/module/mdm/controller/admin/item/MdmItemController.java
@@ -85,6 +85,15 @@
        return success(true);
    }
    @DeleteMapping("/delete-list")
    @Operation(summary = "批量删除物料")
    @Parameter(name = "ids", description = "编号列表", required = true, example = "1,2,3")
    @PreAuthorize("@ss.hasPermission('mdm:item:delete')")
    public CommonResult<Boolean> deleteItemList(@RequestParam("ids") List<Long> ids) {
        itemService.deleteItemList(ids);
        return success(true);
    }
    @GetMapping("/get")
    @Operation(summary = "获得物料")
    @Parameter(name = "id", description = "编号", required = true, example = "1")
yudao-module-mdm/src/main/java/cn/iocoder/yudao/module/mdm/service/item/MdmItemService.java
@@ -50,6 +50,13 @@
    void deleteItem(Long id);
    /**
     * 批量删除物料
     *
     * @param ids 编号列表
     */
    void deleteItemList(Collection<Long> ids);
    /**
     * 获得物料
     *
     * @param id 编号
yudao-module-mdm/src/main/java/cn/iocoder/yudao/module/mdm/service/item/MdmItemServiceImpl.java
@@ -187,6 +187,16 @@
    }
    @Override
    @Transactional(rollbackFor = Exception.class)
    public void deleteItemList(Collection<Long> ids) {
        if (CollUtil.isEmpty(ids)) {
            return;
        }
        // 逐个删除(复用单条删除逻辑,保持校验)
        ids.forEach(this::deleteItem);
    }
    @Override
    public MdmItemDO validateItemExists(Long id) {
        MdmItemDO item = itemMapper.selectById(id);
        if (item == null) {
yudao-module-mes-api/src/main/java/cn/iocoder/yudao/module/mes/api/mps/MesProMpsApi.java
@@ -3,6 +3,7 @@
import cn.iocoder.yudao.module.mes.api.mps.dto.MesProMpsCreateReqDTO;
import cn.iocoder.yudao.module.mes.api.mps.dto.MesProMpsRespDTO;
import java.math.BigDecimal;
import java.util.List;
/**
@@ -76,4 +77,23 @@
     */
    MesProMpsRespDTO getMpsByWorkOrderId(Long workOrderId);
    /**
     * 更新 MPS 的已生产数量(累加)
     *
     * @param mpsId      MPS 编号
     * @param quantity   增加的数量
     */
    void updateMpsProducedQuantity(Long mpsId, BigDecimal quantity);
    /**
     * 检查并自动完成 MPS
     *
     * 如果 MPS 满足完成条件(已生产数量 >= 计划数量 且 所有报工已完成),
     * 则自动将 MPS 状态更新为已完成
     *
     * @param mpsId MPS 编号
     * @return true=已完成或本次自动完成,false=未完成
     */
    Boolean checkAndFinishMps(Long mpsId);
}
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/api/mps/MesProMpsApiImpl.java
@@ -11,6 +11,7 @@
import org.springframework.stereotype.Service;
import org.springframework.validation.annotation.Validated;
import java.math.BigDecimal;
import java.util.Collections;
import java.util.List;
import java.util.Map;
@@ -106,4 +107,14 @@
        return BeanUtils.toBean(mps, MesProMpsRespDTO.class);
    }
    @Override
    public void updateMpsProducedQuantity(Long mpsId, BigDecimal quantity) {
        mpsService.updateProducedQuantity(mpsId, quantity);
    }
    @Override
    public Boolean checkAndFinishMps(Long mpsId) {
        return mpsService.checkAndFinishMps(mpsId);
    }
}
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/service/pro/feedback/MesProFeedbackServiceImpl.java
@@ -195,9 +195,14 @@
            }
        }
        // 4. 关键工序:生成产出单,并根据是否需要检验决定入库方式
        // 5. 先更新报工状态为已完成(确保 checkAndFinishMps 检查时当前报工已标记完成)
        feedbackMapper.updateById(new MesProFeedbackDO().setId(id)
                .setStatus(MesProFeedbackStatusEnum.FINISHED.getStatus())
                .setUncheckQuantity(BigDecimal.ZERO));
        // 6. 关键工序:生成产出单,并根据是否需要检验决定入库方式
        if (keyFlag) {
            // 4.1 需要检验:生成产出单(质量状态=待检验),更新报工状态为待检验,等质检完成回调后再入库
            // 6.1 需要检验:生成产出单(质量状态=待检验),更新报工状态为待检验,等质检完成回调后再入库
            if (checkFlag) {
                // 完成时回调见:MesQcIpqcServiceImpl#finishIpqc → splitPendingAndFinishProduce + completeFeedbackFromIpqc
                productProduceService.generateProductProduce(feedback, true);
@@ -205,22 +210,20 @@
                        .setStatus(MesProFeedbackStatusEnum.UNCHECK.getStatus()));
                return false;
            }
            // 4.2 无需检验:生成产出单(按合格/不合格拆行),直接完成入库,并更新任务/工单的已生产数量
            // 6.2 无需检验:生成产出单(按合格/不合格拆行),直接完成入库,并更新任务/工单的已生产数量
            MesWmProductProduceDO produce = productProduceService.generateProductProduce(feedback, false);
            productProduceService.finishProductProduce(produce.getId());
            updateTaskAndWorkOrderByFeedback(feedback);
        } else {
            // 4.3 非关键工序:只更新任务的已生产数量(工单不更新,工单只看关键工序产出)
            // 6.3 非关键工序:只更新任务的已生产数量(工单不更新,工单只看关键工序产出)
            taskService.updateProducedQuantity(feedback.getTaskId(),
                    feedback.getFeedbackQuantity(),
                    ObjectUtil.defaultIfNull(feedback.getQualifiedQuantity(), BigDecimal.ZERO),
                    ObjectUtil.defaultIfNull(feedback.getUnqualifiedQuantity(), BigDecimal.ZERO));
            // 6.4 非关键工序也需要检查 MPS 完成状态(所有工序报工完成才算完成)
            checkAndPublishProductionFinished(feedback.getWorkOrderId());
        }
        // 5. 非关键工序 / 关键非质检工序:直接完成(清零 uncheckQuantity 防止 !key+check 留脏数据)
        feedbackMapper.updateById(new MesProFeedbackDO().setId(id)
                .setStatus(MesProFeedbackStatusEnum.FINISHED.getStatus())
                .setUncheckQuantity(BigDecimal.ZERO));
        return true; // 已完成
    }
@@ -256,7 +259,13 @@
        workOrderService.updateProducedQuantity(feedback.getWorkOrderId(),
                feedback.getFeedbackQuantity());
        // 4. 检查并发布生产完成事件
        // 4. 更新 MPS 的已生产数量(如果是销售订单来源)
        MesProMpsRespDTO mps = mpsApi.getMpsByWorkOrderId(feedback.getWorkOrderId());
        if (mps != null) {
            mpsApi.updateMpsProducedQuantity(mps.getId(), feedback.getFeedbackQuantity());
        }
        // 5. 检查并发布生产完成事件
        checkAndPublishProductionFinished(feedback.getWorkOrderId());
    }
@@ -270,7 +279,10 @@
            return; // 非销售订单来源的工单,跳过
        }
        // 2. 检查销售订单所有生产是否完成
        // 2. 检查并自动完成 MPS(如果满足条件)
        mpsApi.checkAndFinishMps(mps.getId());
        // 3. 检查销售订单所有生产是否完成
        if (mpsApi.checkSaleOrderProductionFinished(mps.getSaleOrderId())) {
            eventPublisher.publishEvent(new ProductionFinishedEvent(mps.getSaleOrderId(), java.time.LocalDateTime.now()));
        }
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/service/pro/mps/MesProMpsService.java
@@ -165,4 +165,15 @@
     */
    MesProMpsDO getMpsByWorkOrderId(Long workOrderId);
    /**
     * 检查并自动完成 MPS
     *
     * 如果 MPS 满足完成条件(已生产数量 >= 计划数量 且 所有报工已完成),
     * 则自动将 MPS 状态更新为已完成
     *
     * @param mpsId MPS 编号
     * @return true=已完成或本次自动完成,false=未完成
     */
    Boolean checkAndFinishMps(Long mpsId);
}
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/service/pro/mps/MesProMpsServiceImpl.java
@@ -365,6 +365,40 @@
        return mpsMapper.selectOne(MesProMpsDO::getWorkOrderId, workOrderId);
    }
    @Override
    @Transactional(rollbackFor = Exception.class)
    public Boolean checkAndFinishMps(Long mpsId) {
        if (mpsId == null) {
            log.warn("[checkAndFinishMps] mpsId is null");
            return false;
        }
        MesProMpsDO mps = mpsMapper.selectById(mpsId);
        if (mps == null) {
            log.warn("[checkAndFinishMps] MPS not found, mpsId={}", mpsId);
            return false;
        }
        // 已完成或已取消,直接返回 true
        if (MesProMpsStatusEnum.FINISHED.getStatus().equals(mps.getStatus())
                || MesProMpsStatusEnum.CANCELED.getStatus().equals(mps.getStatus())) {
            log.info("[checkAndFinishMps] MPS {} already finished or canceled, status={}", mpsId, mps.getStatus());
            return true;
        }
        // 检查是否满足完成条件
        boolean finished = isMpsFinished(mps);
        log.info("[checkAndFinishMps] MPS {} check result: {}, quantity={}, produced={}, workOrderId={}",
                mpsId, finished, mps.getQuantity(), mps.getQuantityProduced(), mps.getWorkOrderId());
        if (finished) {
            // 自动完成 MPS
            mpsMapper.updateById(new MesProMpsDO()
                    .setId(mpsId)
                    .setStatus(MesProMpsStatusEnum.FINISHED.getStatus())
                    .setFinishDate(LocalDateTime.now()));
            log.info("[checkAndFinishMps] MPS {} auto finished", mpsId);
            return true;
        }
        return false;
    }
    /**
     * 判断单个 MPS 是否完成
     */
@@ -372,6 +406,8 @@
        // 1. 已生产数量 >= 计划数量
        BigDecimal quantityProduced = ObjUtil.defaultIfNull(mps.getQuantityProduced(), BigDecimal.ZERO);
        if (quantityProduced.compareTo(mps.getQuantity()) < 0) {
            log.debug("[isMpsFinished] MPS {} quantity not enough: produced={}, required={}",
                    mps.getId(), quantityProduced, mps.getQuantity());
            return false;
        }
        // 2. 检查工单下所有报工是否完成(确保质检完成)
@@ -380,6 +416,7 @@
                    new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<MesProFeedbackDO>()
                            .eq(MesProFeedbackDO::getWorkOrderId, mps.getWorkOrderId())
                            .ne(MesProFeedbackDO::getStatus, MesProFeedbackStatusEnum.FINISHED.getStatus()));
            log.debug("[isMpsFinished] MPS {} unfinished feedback count: {}", mps.getId(), unfinishedCount);
            if (unfinishedCount > 0) {
                return false;
            }