2026-08-03 dd3d2882b06cc7c148ed5a28bd012de322ef8fc2
feat(mes): 支持 MPS ID 的 AI 预测功能

- 在 MesProAiDeliveryPredictReqVO、MesProAiDurationPredictReqVO、MesProAiMaterialPredictReqVO 和 MesProAiRiskPredictReqVO 中移除工单 ID 的必填验证并添加 MPS ID 字段
- 添加 MesProMpsService 依赖注入以支持 MPS 相关查询
- 实现基于 MPS ID 的物料数据收集方法 gatherMaterialDataFromMps
- 实现基于 MPS ID 的时长数据收集方法 gatherDurationDataFromMps
- 实现基于 MPS ID 的风险数据收集方法 gatherRiskDataFromMps
- 实现基于 MPS ID 的交付数据收集方法 gatherDeliveryDataFromMps
- 修改 AI 预测逻辑以支持工单 ID 和 MPS ID 二选一传入
- 更新日志记录以包含 MPS ID 信息
- 在 application-dev.yaml 中禁用 DeepSeek 自动配置
- 在 application-test.yaml 中批量禁用多个 AI 模型的自动配置
已修改7个文件
223 ■■■■ 文件已修改
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/controller/admin/pro/ai/vo/MesProAiDeliveryPredictReqVO.java 7 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/controller/admin/pro/ai/vo/MesProAiDurationPredictReqVO.java 7 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/controller/admin/pro/ai/vo/MesProAiMaterialPredictReqVO.java 7 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/controller/admin/pro/ai/vo/MesProAiRiskPredictReqVO.java 7 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/service/pro/ai/MesProAiServiceImpl.java 177 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-server/src/main/resources/application-dev.yaml 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-server/src/main/resources/application-test.yaml 16 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/controller/admin/pro/ai/vo/MesProAiDeliveryPredictReqVO.java
@@ -1,15 +1,16 @@
package cn.iocoder.yudao.module.mes.controller.admin.pro.ai.vo;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotNull;
import lombok.Data;
@Schema(description = "管理后台 - AI 按时交付预测请求 VO")
@Data
public class MesProAiDeliveryPredictReqVO {
    @Schema(description = "工单 ID", requiredMode = Schema.RequiredMode.REQUIRED, example = "1")
    @NotNull(message = "工单 ID 不能为空")
    @Schema(description = "工单 ID", example = "1")
    private Long workOrderId;
    @Schema(description = "MPS ID(与工单 ID 二选一)", example = "1")
    private Long mpsId;
}
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/controller/admin/pro/ai/vo/MesProAiDurationPredictReqVO.java
@@ -1,15 +1,16 @@
package cn.iocoder.yudao.module.mes.controller.admin.pro.ai.vo;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotNull;
import lombok.Data;
@Schema(description = "管理后台 - AI 生产时长预测请求 VO")
@Data
public class MesProAiDurationPredictReqVO {
    @Schema(description = "工单 ID", requiredMode = Schema.RequiredMode.REQUIRED, example = "1")
    @NotNull(message = "工单 ID 不能为空")
    @Schema(description = "工单 ID", example = "1")
    private Long workOrderId;
    @Schema(description = "MPS ID(与工单 ID 二选一)", example = "1")
    private Long mpsId;
}
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/controller/admin/pro/ai/vo/MesProAiMaterialPredictReqVO.java
@@ -1,15 +1,16 @@
package cn.iocoder.yudao.module.mes.controller.admin.pro.ai.vo;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotNull;
import lombok.Data;
@Schema(description = "管理后台 - AI 物料短缺预测请求 VO")
@Data
public class MesProAiMaterialPredictReqVO {
    @Schema(description = "工单 ID", requiredMode = Schema.RequiredMode.REQUIRED, example = "1")
    @NotNull(message = "工单 ID 不能为空")
    @Schema(description = "工单 ID", example = "1")
    private Long workOrderId;
    @Schema(description = "MPS ID(与工单 ID 二选一)", example = "1")
    private Long mpsId;
}
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/controller/admin/pro/ai/vo/MesProAiRiskPredictReqVO.java
@@ -1,15 +1,16 @@
package cn.iocoder.yudao.module.mes.controller.admin.pro.ai.vo;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotNull;
import lombok.Data;
@Schema(description = "管理后台 - AI 生产风险预测请求 VO")
@Data
public class MesProAiRiskPredictReqVO {
    @Schema(description = "工单 ID", requiredMode = Schema.RequiredMode.REQUIRED, example = "1")
    @NotNull(message = "工单 ID 不能为空")
    @Schema(description = "工单 ID", example = "1")
    private Long workOrderId;
    @Schema(description = "MPS ID(与工单 ID 二选一)", example = "1")
    private Long mpsId;
}
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/service/pro/ai/MesProAiServiceImpl.java
@@ -30,6 +30,7 @@
import cn.iocoder.yudao.module.mes.dal.mysql.pro.workorder.MesProWorkOrderBomMapper;
import cn.iocoder.yudao.module.mes.dal.mysql.qc.ipqc.MesQcIpqcMapper;
import cn.iocoder.yudao.module.mes.dal.mysql.wm.materialstock.MesWmMaterialStockMapper;
import cn.iocoder.yudao.module.mes.service.pro.mps.MesProMpsService;
import cn.iocoder.yudao.module.mes.service.pro.process.MesProProcessService;
import cn.iocoder.yudao.module.mes.service.pro.workorder.MesProWorkOrderProcessService;
import cn.iocoder.yudao.module.mes.service.pro.workorder.MesProWorkOrderService;
@@ -169,6 +170,8 @@
    @Resource
    private MesProMpsMapper mpsMapper;
    @Resource
    private MesProMpsService mpsService;
    @Resource
    private MesProProcessService processService;
    @Resource
    private MdmItemApi mdmItemApi;
@@ -182,12 +185,20 @@
    @Override
    public MesProAiMaterialPredictRespVO predictMaterial(MesProAiMaterialPredictReqVO reqVO) {
        try {
            Map<String, Object> data = gatherMaterialData(reqVO.getWorkOrderId());
            Map<String, Object> data;
            String logPrefix;
            if (reqVO.getMpsId() != null) {
                data = gatherMaterialDataFromMps(reqVO.getMpsId());
                logPrefix = "mpsId=" + reqVO.getMpsId();
            } else {
                data = gatherMaterialData(reqVO.getWorkOrderId());
                logPrefix = "workOrderId=" + reqVO.getWorkOrderId();
            }
            String aiResponse = aiChatApi.chat(SYSTEM_PROMPT_MATERIAL, JSONUtil.toJsonPrettyStr(data));
            log.info("AI 物料预测: workOrderId={}, response={}", reqVO.getWorkOrderId(), aiResponse);
            log.info("AI 物料预测: {}, response={}", logPrefix, aiResponse);
            return parseMaterialResponse(aiResponse);
        } catch (Exception e) {
            log.warn("AI 物料预测失败,workOrderId={}", reqVO.getWorkOrderId(), e);
            log.warn("AI 物料预测失败,workOrderId={}, mpsId={}", reqVO.getWorkOrderId(), reqVO.getMpsId(), e);
            MesProAiMaterialPredictRespVO fallback = new MesProAiMaterialPredictRespVO();
            fallback.setReasoning("AI 分析暂时不可用,请稍后重试");
            return fallback;
@@ -258,6 +269,38 @@
        return data;
    }
    private Map<String, Object> gatherMaterialDataFromMps(Long mpsId) {
        Map<String, Object> data = new LinkedHashMap<>();
        MesProMpsDO mps = mpsService.getMps(mpsId);
        if (mps == null) throw exception(PRO_WORK_ORDER_NOT_EXISTS);
        data.put("MPS 编号", mps.getCode());
        data.put("来源", "MPS 未下发,基于产品信息预测");
        data.put("计划数量", mps.getQuantity());
        data.put("已生产数量", mps.getQuantityProduced() != null ? mps.getQuantityProduced() : BigDecimal.ZERO);
        fillItemInfo(data, mps.getProductId());
        // 查询产品在库物料库存(通用,非工单特定 BOM)
        List<MesWmMaterialStockDO> stocks = materialStockMapper.selectList(
                new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<MesWmMaterialStockDO>()
                        .ne(MesWmMaterialStockDO::getQuantity, BigDecimal.ZERO));
        if (CollUtil.isNotEmpty(stocks)) {
            List<Map<String, Object>> materials = new ArrayList<>();
            for (MesWmMaterialStockDO stock : stocks) {
                Map<String, Object> item = new LinkedHashMap<>();
                item.put("物料ID", stock.getItemId());
                item.put("物料名称", resolveItemName(stock.getItemId()));
                item.put("库存可用量", stock.getQuantity());
                materials.add(item);
            }
            data.put("当前库存物料", materials);
        } else {
            data.put("提示", "暂无库存数据,请参考产品编码评估物料需求");
        }
        return data;
    }
    private MesProAiMaterialPredictRespVO parseMaterialResponse(String aiResponse) {
        MesProAiMaterialPredictRespVO respVO = new MesProAiMaterialPredictRespVO();
        try {
@@ -293,12 +336,20 @@
    @Override
    public MesProAiDurationPredictRespVO predictDuration(MesProAiDurationPredictReqVO reqVO) {
        try {
            Map<String, Object> data = gatherDurationData(reqVO.getWorkOrderId());
            Map<String, Object> data;
            String logPrefix;
            if (reqVO.getMpsId() != null) {
                data = gatherDurationDataFromMps(reqVO.getMpsId());
                logPrefix = "mpsId=" + reqVO.getMpsId();
            } else {
                data = gatherDurationData(reqVO.getWorkOrderId());
                logPrefix = "workOrderId=" + reqVO.getWorkOrderId();
            }
            String aiResponse = aiChatApi.chat(SYSTEM_PROMPT_DURATION, JSONUtil.toJsonPrettyStr(data));
            log.info("AI 时长预测: workOrderId={}, response={}", reqVO.getWorkOrderId(), aiResponse);
            log.info("AI 时长预测: {}, response={}", logPrefix, aiResponse);
            return parseDurationResponse(aiResponse);
        } catch (Exception e) {
            log.warn("AI 时长预测失败,workOrderId={}", reqVO.getWorkOrderId(), e);
            log.warn("AI 时长预测失败,workOrderId={}, mpsId={}", reqVO.getWorkOrderId(), reqVO.getMpsId(), e);
            MesProAiDurationPredictRespVO fallback = new MesProAiDurationPredictRespVO();
            fallback.setReasoning("AI 分析暂时不可用,请稍后重试");
            return fallback;
@@ -389,6 +440,22 @@
        return totalEfficiency;
    }
    private Map<String, Object> gatherDurationDataFromMps(Long mpsId) {
        Map<String, Object> data = new LinkedHashMap<>();
        MesProMpsDO mps = mpsService.getMps(mpsId);
        if (mps == null) throw exception(PRO_WORK_ORDER_NOT_EXISTS);
        data.put("MPS 编号", mps.getCode());
        data.put("来源", "MPS 未下发,基于产品信息估算");
        data.put("计划数量", mps.getQuantity());
        data.put("已生产数量", mps.getQuantityProduced() != null ? mps.getQuantityProduced() : BigDecimal.ZERO);
        data.put("需求日期", mps.getRequestDate() != null ? mps.getRequestDate().toString() : "未设置");
        fillItemInfo(data, mps.getProductId());
        data.put("提示", "MPS 未关联工单工序,请基于产品编码和经验估算生产时长");
        return data;
    }
    private MesProAiDurationPredictRespVO parseDurationResponse(String aiResponse) {
        MesProAiDurationPredictRespVO respVO = new MesProAiDurationPredictRespVO();
        try {
@@ -426,12 +493,20 @@
    @Override
    public MesProAiRiskPredictRespVO predictRisk(MesProAiRiskPredictReqVO reqVO) {
        try {
            Map<String, Object> data = gatherRiskData(reqVO.getWorkOrderId());
            Map<String, Object> data;
            String logPrefix;
            if (reqVO.getMpsId() != null) {
                data = gatherRiskDataFromMps(reqVO.getMpsId());
                logPrefix = "mpsId=" + reqVO.getMpsId();
            } else {
                data = gatherRiskData(reqVO.getWorkOrderId());
                logPrefix = "workOrderId=" + reqVO.getWorkOrderId();
            }
            String aiResponse = aiChatApi.chat(SYSTEM_PROMPT_RISK, JSONUtil.toJsonPrettyStr(data));
            log.info("AI 风险预测: workOrderId={}, response={}", reqVO.getWorkOrderId(), aiResponse);
            log.info("AI 风险预测: {}, response={}", logPrefix, aiResponse);
            return parseRiskResponse(aiResponse);
        } catch (Exception e) {
            log.warn("AI 风险预测失败,workOrderId={}", reqVO.getWorkOrderId(), e);
            log.warn("AI 风险预测失败,workOrderId={}, mpsId={}", reqVO.getWorkOrderId(), reqVO.getMpsId(), e);
            MesProAiRiskPredictRespVO fallback = new MesProAiRiskPredictRespVO();
            fallback.setReasoning("AI 分析暂时不可用,请稍后重试");
            return fallback;
@@ -560,6 +635,40 @@
        return data;
    }
    private Map<String, Object> gatherRiskDataFromMps(Long mpsId) {
        Map<String, Object> data = new LinkedHashMap<>();
        MesProMpsDO mps = mpsService.getMps(mpsId);
        if (mps == null) throw exception(PRO_WORK_ORDER_NOT_EXISTS);
        data.put("MPS 编号", mps.getCode());
        data.put("来源", "MPS 未下发,风险评估基于产品信息");
        data.put("计划数量", mps.getQuantity());
        data.put("已生产数量", mps.getQuantityProduced() != null ? mps.getQuantityProduced() : BigDecimal.ZERO);
        data.put("需求日期", mps.getRequestDate() != null ? mps.getRequestDate().toString() : "未设置");
        data.put("下发日期", mps.getIssueDate() != null ? mps.getIssueDate().toString() : "未下发");
        fillItemInfo(data, mps.getProductId());
        // 产品库存情况
        List<MesWmMaterialStockDO> stocks = materialStockMapper.selectList(
                new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<MesWmMaterialStockDO>()
                        .ne(MesWmMaterialStockDO::getQuantity, BigDecimal.ZERO));
        if (CollUtil.isNotEmpty(stocks)) {
            List<Map<String, Object>> stockList = new ArrayList<>();
            for (MesWmMaterialStockDO stock : stocks) {
                Map<String, Object> item = new LinkedHashMap<>();
                item.put("物料ID", stock.getItemId());
                item.put("物料名称", resolveItemName(stock.getItemId()));
                item.put("可用量", stock.getQuantity());
                stockList.add(item);
            }
            data.put("库存物料", stockList);
        }
        data.put("提示", "MPS 未下发无工单数据,请结合产品特性和库存自行判断风险");
        return data;
    }
    private MesProAiRiskPredictRespVO parseRiskResponse(String aiResponse) {
        MesProAiRiskPredictRespVO respVO = new MesProAiRiskPredictRespVO();
        try {
@@ -595,12 +704,20 @@
    @Override
    public MesProAiDeliveryPredictRespVO predictDelivery(MesProAiDeliveryPredictReqVO reqVO) {
        try {
            Map<String, Object> data = gatherDeliveryData(reqVO.getWorkOrderId());
            Map<String, Object> data;
            String logPrefix;
            if (reqVO.getMpsId() != null) {
                data = gatherDeliveryDataFromMps(reqVO.getMpsId());
                logPrefix = "mpsId=" + reqVO.getMpsId();
            } else {
                data = gatherDeliveryData(reqVO.getWorkOrderId());
                logPrefix = "workOrderId=" + reqVO.getWorkOrderId();
            }
            String aiResponse = aiChatApi.chat(SYSTEM_PROMPT_DELIVERY, JSONUtil.toJsonPrettyStr(data));
            log.info("AI 交付预测: workOrderId={}, response={}", reqVO.getWorkOrderId(), aiResponse);
            log.info("AI 交付预测: {}, response={}", logPrefix, aiResponse);
            return parseDeliveryResponse(aiResponse);
        } catch (Exception e) {
            log.warn("AI 交付预测失败,workOrderId={}", reqVO.getWorkOrderId(), e);
            log.warn("AI 交付预测失败,workOrderId={}, mpsId={}", reqVO.getWorkOrderId(), reqVO.getMpsId(), e);
            MesProAiDeliveryPredictRespVO fallback = new MesProAiDeliveryPredictRespVO();
            fallback.setReasoning("AI 分析暂时不可用,请稍后重试");
            return fallback;
@@ -681,6 +798,42 @@
        return data;
    }
    private Map<String, Object> gatherDeliveryDataFromMps(Long mpsId) {
        Map<String, Object> data = new LinkedHashMap<>();
        MesProMpsDO mps = mpsService.getMps(mpsId);
        if (mps == null) throw exception(PRO_WORK_ORDER_NOT_EXISTS);
        data.put("MPS 编号", mps.getCode());
        data.put("来源", "MPS 未下发,基于产品信息评估");
        data.put("计划数量", mps.getQuantity());
        data.put("已生产数量", mps.getQuantityProduced() != null ? mps.getQuantityProduced() : BigDecimal.ZERO);
        data.put("MPS 状态", mps.getStatus());
        data.put("销售订单号", mps.getSaleOrderNo());
        data.put("需求日期", mps.getRequestDate() != null ? mps.getRequestDate().toString() : "未设置");
        data.put("下发日期", mps.getIssueDate() != null ? mps.getIssueDate().toString() : "未下发");
        fillItemInfo(data, mps.getProductId());
        fillClientInfo(data, mps.getClientId());
        // 进度计算
        BigDecimal progressPercent = BigDecimal.ZERO;
        if (mps.getQuantity() != null && mps.getQuantity().compareTo(BigDecimal.ZERO) > 0
                && mps.getQuantityProduced() != null) {
            progressPercent = mps.getQuantityProduced()
                    .divide(mps.getQuantity(), 4, RoundingMode.HALF_UP)
                    .multiply(BigDecimal.valueOf(100));
        }
        data.put("生产进度(%)", progressPercent.setScale(1, RoundingMode.HALF_UP));
        // 剩余天数
        int remainingDays = 0;
        if (mps.getRequestDate() != null) {
            remainingDays = (int) ChronoUnit.DAYS.between(LocalDate.now(), mps.getRequestDate().toLocalDate());
        }
        data.put("距离需求日期剩余天数", Math.max(0, remainingDays));
        return data;
    }
    private MesProAiDeliveryPredictRespVO parseDeliveryResponse(String aiResponse) {
        MesProAiDeliveryPredictRespVO respVO = new MesProAiDeliveryPredictRespVO();
        try {
yudao-server/src/main/resources/application-dev.yaml
@@ -11,6 +11,8 @@
      - org.springframework.ai.vectorstore.redis.autoconfigure.RedisVectorStoreAutoConfiguration # 禁用 AI 模块的 Redis,手动创建
      - org.springframework.ai.vectorstore.milvus.autoconfigure.MilvusVectorStoreAutoConfiguration # 禁用 AI 模块的 Milvus,手动创建
      - org.dromara.trans.config.TransServiceConfig # TODO 芋艿:easy-trans 兼容 Spring Boot 4 后移除(TransServiceConfig 引用了已移除的 RestTemplateBuilder)
      # 禁用未配置 API Key 的模型自动配置
      - org.springframework.ai.model.deepseek.autoconfigure.DeepSeekChatAutoConfiguration
  # 数据源配置项
  datasource:
    druid: # Druid 【监控】相关的全局配置
yudao-server/src/main/resources/application-test.yaml
@@ -11,6 +11,22 @@
      - org.springframework.ai.vectorstore.redis.autoconfigure.RedisVectorStoreAutoConfiguration # 禁用 AI 模块的 Redis,手动创建
      - org.springframework.ai.vectorstore.milvus.autoconfigure.MilvusVectorStoreAutoConfiguration # 禁用 AI 模块的 Milvus,手动创建
      - org.dromara.trans.config.TransServiceConfig # TODO 芋艿:easy-trans 兼容 Spring Boot 4 后移除(TransServiceConfig 引用了已移除的 RestTemplateBuilder)
      # 禁用未配置 API Key 的模型自动配置(只保留通义千问 Embedding 的手动配置)
      - org.springframework.ai.model.openai.autoconfigure.OpenAiChatAutoConfiguration
      - org.springframework.ai.model.openai.autoconfigure.OpenAiEmbeddingAutoConfiguration
      - org.springframework.ai.model.openai.autoconfigure.OpenAiImageAutoConfiguration
      - org.springframework.ai.model.openai.autoconfigure.OpenAiAudioSpeechAutoConfiguration
      - org.springframework.ai.model.openai.autoconfigure.OpenAiAudioTranscriptionAutoConfiguration
      - org.springframework.ai.model.openai.autoconfigure.OpenAiModerationAutoConfiguration
      - org.springframework.ai.model.deepseek.autoconfigure.DeepSeekChatAutoConfiguration
      - org.springframework.ai.model.anthropic.autoconfigure.AnthropicChatAutoConfiguration
      - org.springframework.ai.model.google.genai.autoconfigure.chat.GoogleGenAiChatAutoConfiguration
      - org.springframework.ai.model.google.genai.autoconfigure.embedding.GoogleGenAiEmbeddingConnectionAutoConfiguration
      - org.springframework.ai.model.google.genai.autoconfigure.embedding.GoogleGenAiTextEmbeddingAutoConfiguration
      - org.springframework.ai.model.ollama.autoconfigure.OllamaApiAutoConfiguration
      - org.springframework.ai.model.ollama.autoconfigure.OllamaChatAutoConfiguration
      - org.springframework.ai.model.ollama.autoconfigure.OllamaEmbeddingAutoConfiguration
      - org.springframework.ai.model.stabilityai.autoconfigure.StabilityAiImageAutoConfiguration
  # 数据源配置项
  datasource:
    druid: # Druid 【监控】相关的全局配置