liu
2026-10-19 71e87ac75fdeb98efa3005198c238d5e62ab56e2
fix(mes): AI 预测在未配置大模型时回退本地规则引擎

- 物料:BOM 需求量与库存可用量(在库-冻结-占用)比对
- 时长:工艺路线准备/等待时间 + 剩余数量/设备效率,预留 10% 缓冲
- 风险:IPQC 缺陷率、设备状态/保养点检、物料缺口、关键工序
- 交付:生产进度、剩余交期(8 小时/天)、剩余工时、未完成任务、物料缺口
- MPS 已下发时优先使用关联工单数据

Co-Authored-By: Claude <noreply@anthropic.com>
已修改1个文件
687 ■■■■■ 文件已修改
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/service/pro/ai/MesProAiServiceImpl.java 687 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/service/pro/ai/MesProAiServiceImpl.java
@@ -10,6 +10,7 @@
import cn.iocoder.yudao.module.mdm.api.item.dto.MdmItemRespDTO;
import cn.iocoder.yudao.module.mes.controller.admin.pro.ai.vo.*;
import cn.iocoder.yudao.module.mes.dal.dataobject.dv.machinery.MesDvMachineryDO;
import cn.iocoder.yudao.module.mes.dal.dataobject.md.item.MesMdProductBomDO;
import cn.iocoder.yudao.module.mes.dal.dataobject.md.workstation.MesMdWorkstationMachineDO;
import cn.iocoder.yudao.module.mes.dal.dataobject.pro.feedback.MesProFeedbackDO;
import cn.iocoder.yudao.module.mes.dal.dataobject.pro.mps.MesProMpsDO;
@@ -22,6 +23,7 @@
import cn.iocoder.yudao.module.mes.dal.dataobject.qc.ipqc.MesQcIpqcDO;
import cn.iocoder.yudao.module.mes.dal.dataobject.wm.materialstock.MesWmMaterialStockDO;
import cn.iocoder.yudao.module.mes.dal.mysql.dv.machinery.MesDvMachineryMapper;
import cn.iocoder.yudao.module.mes.dal.mysql.md.item.MesMdProductBomMapper;
import cn.iocoder.yudao.module.mes.dal.mysql.md.workstation.MesMdWorkstationMachineMapper;
import cn.iocoder.yudao.module.mes.dal.mysql.pro.feedback.MesProFeedbackMapper;
import cn.iocoder.yudao.module.mes.dal.mysql.pro.mps.MesProMpsMapper;
@@ -152,6 +154,8 @@
    @Resource
    private MesProWorkOrderBomMapper workOrderBomMapper;
    @Resource
    private MesMdProductBomMapper productBomMapper;
    @Resource
    private MesProWorkOrderProcessService workOrderProcessService;
    @Resource
    private MesProTaskMapper taskMapper;
@@ -198,11 +202,209 @@
            log.info("AI 物料预测: {}, response={}", logPrefix, aiResponse);
            return parseMaterialResponse(aiResponse);
        } catch (Exception e) {
            log.warn("AI 物料预测失败,workOrderId={}, mpsId={}", reqVO.getWorkOrderId(), reqVO.getMpsId(), e);
            MesProAiMaterialPredictRespVO fallback = new MesProAiMaterialPredictRespVO();
            fallback.setReasoning("AI 分析暂时不可用,请稍后重试");
            return fallback;
            log.warn("AI 物料预测失败,workOrderId={}, mpsId={},切换本地规则核算",
                    reqVO.getWorkOrderId(), reqVO.getMpsId(), e);
            // AI 大模型未配置或调用失败时,退化为本地规则核算,保证功能可用
            return buildMaterialFallback(reqVO);
        }
    }
    /**
     * AI 不可用时的本地兜底:基于 BOM 需求量与实时库存可用量做确定性核算。
     *
     * 库存可用量 = 在库数量 - 冻结数量 - 已占用数量
     */
    private MesProAiMaterialPredictRespVO buildMaterialFallback(MesProAiMaterialPredictReqVO reqVO) {
        MesProAiMaterialPredictRespVO respVO = new MesProAiMaterialPredictRespVO();
        try {
            List<MaterialDemand> demands = reqVO.getMpsId() != null
                    ? buildMaterialDemandsFromMps(reqVO.getMpsId())
                    : buildMaterialDemandsFromWorkOrder(reqVO.getWorkOrderId());
            if (CollUtil.isEmpty(demands)) {
                respVO.setRiskLevel(1);
                respVO.setRiskItems(Collections.emptyList());
                respVO.setReasoning("AI 服务暂不可用,且未找到可核算的 BOM 物料,无法进行物料短缺预测。"
                        + "请先维护产品 BOM,或在「AI 大模型 - 模型管理」中配置可用的对话模型。");
                return respVO;
            }
            Map<Long, BigDecimal> stockMap = getAvailableStockMap(
                    demands.stream().map(MaterialDemand::itemId).collect(Collectors.toSet()));
            List<MesProAiMaterialPredictRespVO.RiskItem> riskItems = new ArrayList<>(demands.size());
            boolean hasShortage = false;
            boolean hasTight = false;
            int shortageCount = 0;
            for (MaterialDemand demand : demands) {
                BigDecimal required = demand.requiredQuantity();
                BigDecimal available = stockMap.getOrDefault(demand.itemId(), BigDecimal.ZERO);
                BigDecimal shortage = required.subtract(available).max(BigDecimal.ZERO);
                String level;
                String suggestion;
                if (shortage.compareTo(BigDecimal.ZERO) <= 0) {
                    level = "充足";
                    suggestion = "库存充足,无需补充";
                } else if (available.compareTo(required.multiply(BigDecimal.valueOf(0.5))) >= 0) {
                    level = "紧张";
                    suggestion = "库存偏紧,建议尽快采购或调拨,缺口 "
                            + shortage.stripTrailingZeros().toPlainString();
                    hasTight = true;
                } else {
                    level = "短缺";
                    suggestion = "库存不足,建议紧急采购或调拨,缺口 "
                            + shortage.stripTrailingZeros().toPlainString();
                    hasShortage = true;
                    shortageCount++;
                }
                MesProAiMaterialPredictRespVO.RiskItem item = new MesProAiMaterialPredictRespVO.RiskItem();
                MdmItemRespDTO itemInfo = getItemInfoQuietly(demand.itemId());
                item.setItemCode(itemInfo != null && StrUtil.isNotBlank(itemInfo.getCode())
                        ? itemInfo.getCode() : String.valueOf(demand.itemId()));
                item.setItemName(itemInfo != null && StrUtil.isNotBlank(itemInfo.getName())
                        ? itemInfo.getName() : "物料" + demand.itemId());
                item.setRequiredQuantity(required);
                item.setAvailableQuantity(available);
                item.setShortageQuantity(shortage);
                item.setRiskLevel(level);
                item.setSuggestion(suggestion);
                riskItems.add(item);
            }
            // 短缺项优先展示,缺口大的在前
            riskItems.sort(Comparator
                    .comparingInt((MesProAiMaterialPredictRespVO.RiskItem item) -> riskSeverity(item.getRiskLevel()))
                    .reversed()
                    .thenComparing(MesProAiMaterialPredictRespVO.RiskItem::getShortageQuantity,
                            Comparator.reverseOrder()));
            respVO.setRiskLevel(hasShortage ? 3 : (hasTight ? 2 : 1));
            respVO.setRiskItems(riskItems);
            String source = reqVO.getMpsId() != null ? "MPS 产品 BOM" : "工单 BOM";
            respVO.setReasoning(String.format(
                    "AI 大模型暂不可用,已自动切换本地规则引擎完成物料核算:基于%s与实时库存可用量"
                            + "(在库 - 冻结 - 已占用)逐项比对,共核算 %d 项物料,其中短缺 %d 项、紧张 %d 项。",
                    source, demands.size(), shortageCount, riskItems.size() - shortageCount - countSufficient(riskItems)));
        } catch (Exception e) {
            log.warn("本地物料核算失败,workOrderId={}, mpsId={}",
                    reqVO.getWorkOrderId(), reqVO.getMpsId(), e);
            respVO.setRiskLevel(1);
            respVO.setRiskItems(Collections.emptyList());
            respVO.setReasoning("AI 分析暂时不可用,且本地物料数据核算失败,请稍后重试或检查工单/MPS 数据。");
        }
        return respVO;
    }
    private List<MaterialDemand> buildMaterialDemandsFromWorkOrder(Long workOrderId) {
        if (workOrderId == null) {
            return Collections.emptyList();
        }
        List<MesProWorkOrderBomDO> bomList = workOrderBomMapper.selectListByWorkOrderId(workOrderId);
        if (CollUtil.isEmpty(bomList)) {
            return Collections.emptyList();
        }
        Map<Long, BigDecimal> demandByItem = new LinkedHashMap<>();
        for (MesProWorkOrderBomDO bom : bomList) {
            if (bom.getItemId() == null || bom.getQuantity() == null
                    || bom.getQuantity().compareTo(BigDecimal.ZERO) <= 0) {
                continue;
            }
            demandByItem.merge(bom.getItemId(), bom.getQuantity(), BigDecimal::add);
        }
        return toMaterialDemands(demandByItem);
    }
    private List<MaterialDemand> buildMaterialDemandsFromMps(Long mpsId) {
        if (mpsId == null) {
            return Collections.emptyList();
        }
        MesProMpsDO mps = mpsService.getMps(mpsId);
        if (mps == null) {
            return Collections.emptyList();
        }
        // 已下发工单时优先使用工单 BOM(更贴近实际生产需求)
        if (mps.getWorkOrderId() != null) {
            List<MaterialDemand> workOrderDemands = buildMaterialDemandsFromWorkOrder(mps.getWorkOrderId());
            if (CollUtil.isNotEmpty(workOrderDemands)) {
                return workOrderDemands;
            }
        }
        if (mps.getProductId() == null || mps.getQuantity() == null) {
            return Collections.emptyList();
        }
        BigDecimal produced = mps.getQuantityProduced() != null ? mps.getQuantityProduced() : BigDecimal.ZERO;
        BigDecimal remaining = mps.getQuantity().subtract(produced).max(BigDecimal.ZERO);
        if (remaining.compareTo(BigDecimal.ZERO) <= 0) {
            return Collections.emptyList();
        }
        List<MesMdProductBomDO> productBomList = productBomMapper.selectByItemId(mps.getProductId());
        if (CollUtil.isEmpty(productBomList)) {
            return Collections.emptyList();
        }
        Map<Long, BigDecimal> demandByItem = new LinkedHashMap<>();
        for (MesMdProductBomDO bom : productBomList) {
            if (bom.getBomItemId() == null || bom.getQuantity() == null
                    || bom.getQuantity().compareTo(BigDecimal.ZERO) <= 0) {
                continue;
            }
            demandByItem.merge(bom.getBomItemId(), remaining.multiply(bom.getQuantity()), BigDecimal::add);
        }
        return toMaterialDemands(demandByItem);
    }
    private List<MaterialDemand> toMaterialDemands(Map<Long, BigDecimal> demandByItem) {
        List<MaterialDemand> demands = new ArrayList<>(demandByItem.size());
        demandByItem.forEach((itemId, quantity) -> demands.add(new MaterialDemand(itemId, quantity)));
        return demands;
    }
    private Map<Long, BigDecimal> getAvailableStockMap(Collection<Long> itemIds) {
        if (CollUtil.isEmpty(itemIds)) {
            return Collections.emptyMap();
        }
        List<MesWmMaterialStockDO> stocks = materialStockMapper.selectList(
                new LambdaQueryWrapper<MesWmMaterialStockDO>()
                        .in(MesWmMaterialStockDO::getItemId, itemIds));
        Map<Long, BigDecimal> stockMap = new HashMap<>();
        for (MesWmMaterialStockDO stock : stocks) {
            if (stock.getItemId() == null || stock.getQuantity() == null) {
                continue;
            }
            BigDecimal frozen = stock.getFrozenQuantity() != null ? stock.getFrozenQuantity() : BigDecimal.ZERO;
            BigDecimal reserved = stock.getReservedQuantity() != null ? stock.getReservedQuantity() : BigDecimal.ZERO;
            BigDecimal available = stock.getQuantity().subtract(frozen).subtract(reserved);
            stockMap.merge(stock.getItemId(), available, BigDecimal::add);
        }
        stockMap.replaceAll((itemId, available) -> available.max(BigDecimal.ZERO));
        return stockMap;
    }
    private MdmItemRespDTO getItemInfoQuietly(Long itemId) {
        if (itemId == null) {
            return null;
        }
        try {
            return mdmItemApi.getItem(itemId).getCheckedData();
        } catch (Exception ignored) {
            return null;
        }
    }
    private int riskSeverity(String riskLevel) {
        return switch (riskLevel) {
            case "短缺" -> 3;
            case "紧张" -> 2;
            case "充足" -> 1;
            default -> 0;
        };
    }
    private long countSufficient(List<MesProAiMaterialPredictRespVO.RiskItem> riskItems) {
        return riskItems.stream().filter(item -> "充足".equals(item.getRiskLevel())).count();
    }
    private record MaterialDemand(Long itemId, BigDecimal requiredQuantity) {
    }
    private Map<String, Object> gatherMaterialData(Long workOrderId) {
@@ -335,9 +537,9 @@
    @Override
    public MesProAiDurationPredictRespVO predictDuration(MesProAiDurationPredictReqVO reqVO) {
        Map<String, Object> data = null;
        String logPrefix;
        try {
            Map<String, Object> data;
            String logPrefix;
            if (reqVO.getMpsId() != null) {
                data = gatherDurationDataFromMps(reqVO.getMpsId());
                logPrefix = "mpsId=" + reqVO.getMpsId();
@@ -349,10 +551,125 @@
            log.info("AI 时长预测: {}, response={}", logPrefix, aiResponse);
            return parseDurationResponse(aiResponse);
        } catch (Exception e) {
            log.warn("AI 时长预测失败,workOrderId={}, mpsId={}", reqVO.getWorkOrderId(), reqVO.getMpsId(), e);
            MesProAiDurationPredictRespVO fallback = new MesProAiDurationPredictRespVO();
            fallback.setReasoning("AI 分析暂时不可用,请稍后重试");
            return fallback;
            log.warn("AI 时长预测失败,workOrderId={}, mpsId={},切换本地规则估算",
                    reqVO.getWorkOrderId(), reqVO.getMpsId(), e);
            return buildDurationFallback(reqVO, data);
        }
    }
    /**
     * AI 不可用时的本地时长估算:标准准备/等待时间 + 计划数量 / 设备效率,生产时间预留 10% 缓冲。
     */
    private MesProAiDurationPredictRespVO buildDurationFallback(MesProAiDurationPredictReqVO reqVO,
                                                               Map<String, Object> gathered) {
        MesProAiDurationPredictRespVO respVO = new MesProAiDurationPredictRespVO();
        try {
            Map<String, Object> data = resolveWorkOrderId(reqVO.getWorkOrderId(), reqVO.getMpsId()) != null
                    ? gatherDurationData(resolveWorkOrderId(reqVO.getWorkOrderId(), reqVO.getMpsId()))
                    : gathered;
            DurationEstimate estimate = computeDurationEstimate(data);
            if (!estimate.hasProcessData()) {
                respVO.setPredictedTotalHours(BigDecimal.ZERO);
                respVO.setStandardTotalHours(BigDecimal.ZERO);
                respVO.setProcessBreakdown(Collections.emptyList());
                respVO.setKeyPoints(Collections.emptyList());
                respVO.setReasoning("AI 大模型暂不可用,且当前工单/MPS 没有可用的工序或设备数据,无法估算生产时长。"
                        + "请先维护工艺路线并完成排产,或在「AI 大模型 - 模型管理」中配置可用的对话模型。");
                return respVO;
            }
            respVO.setPredictedTotalHours(estimate.predictedHours());
            respVO.setStandardTotalHours(estimate.standardHours());
            respVO.setProcessBreakdown(estimate.breakdown());
            respVO.setKeyPoints(estimate.keyPoints());
            respVO.setReasoning("AI 大模型暂不可用,已自动切换本地规则引擎估算:按工艺路线标准准备/等待时间"
                    + "加上「剩余计划数量 ÷ 设备效率」计算生产时间,并对生产时间预留 10% 缓冲。");
        } catch (Exception e) {
            log.warn("本地时长估算失败,workOrderId={}, mpsId={}",
                    reqVO.getWorkOrderId(), reqVO.getMpsId(), e);
            respVO.setPredictedTotalHours(BigDecimal.ZERO);
            respVO.setStandardTotalHours(BigDecimal.ZERO);
            respVO.setProcessBreakdown(Collections.emptyList());
            respVO.setKeyPoints(Collections.emptyList());
            respVO.setReasoning("AI 分析暂时不可用,且本地工序数据核算失败,请稍后重试或检查工艺路线/排产数据。");
        }
        return respVO;
    }
    /** MPS 已下发时优先使用关联工单的数据 */
    private Long resolveWorkOrderId(Long workOrderId, Long mpsId) {
        if (workOrderId != null) {
            return workOrderId;
        }
        if (mpsId != null) {
            MesProMpsDO mps = mpsService.getMps(mpsId);
            if (mps != null) {
                return mps.getWorkOrderId();
            }
        }
        return null;
    }
    @SuppressWarnings("unchecked")
    private DurationEstimate computeDurationEstimate(Map<String, Object> data) {
        if (data == null) {
            return DurationEstimate.empty();
        }
        Object rawProcesses = data.get("工序明细");
        List<Map<String, Object>> processes = rawProcesses instanceof List<?>
                ? (List<Map<String, Object>>) rawProcesses : Collections.emptyList();
        if (CollUtil.isEmpty(processes)) {
            return DurationEstimate.empty();
        }
        BigDecimal prepareWaitHours = toBigDecimal(data.get("标准总准备+等待时间(小时)"));
        BigDecimal standardProduction = BigDecimal.ZERO;
        BigDecimal predictedProduction = BigDecimal.ZERO;
        List<MesProAiDurationPredictRespVO.ProcessTimeBreakdown> breakdown = new ArrayList<>(processes.size());
        List<String> keyPoints = new ArrayList<>();
        int missingEfficiency = 0;
        for (Map<String, Object> proc : processes) {
            MesProAiDurationPredictRespVO.ProcessTimeBreakdown item =
                    new MesProAiDurationPredictRespVO.ProcessTimeBreakdown();
            String processName = (String) proc.get("工序名称");
            item.setProcessName(processName);
            item.setSort(toInteger(proc.get("顺序")));
            item.setStandardPrepareTime(toInteger(proc.get("标准准备时间(分钟)")));
            item.setStandardWaitTime(toInteger(proc.get("标准等待时间(分钟)")));
            BigDecimal productionHours = proc.get("预计生产时间(小时)") != null
                    ? toBigDecimal(proc.get("预计生产时间(小时)")) : BigDecimal.ZERO;
            BigDecimal predictedHours = productionHours.multiply(BigDecimal.valueOf(1.1));
            standardProduction = standardProduction.add(productionHours);
            predictedProduction = predictedProduction.add(predictedHours);
            item.setStandardProductionTime(productionHours.setScale(2, RoundingMode.HALF_UP));
            item.setPredictedProductionTime(predictedHours.setScale(2, RoundingMode.HALF_UP));
            breakdown.add(item);
            if (productionHours.compareTo(BigDecimal.ZERO) <= 0) {
                missingEfficiency++;
            }
            if (Boolean.TRUE.equals(proc.get("是否关键工序"))) {
                keyPoints.add(String.format("关键工序「%s」预计 %.2f 小时(含 10%% 缓冲)",
                        processName, predictedHours.doubleValue()));
            }
        }
        if (missingEfficiency > 0) {
            keyPoints.add(String.format("有 %d 道工序缺少设备效率或排产数据,生产时间按 0 计,建议补齐设备产能后重新预测",
                    missingEfficiency));
        }
        BigDecimal standardHours = prepareWaitHours.add(standardProduction).setScale(2, RoundingMode.HALF_UP);
        BigDecimal predictedHours = prepareWaitHours.add(predictedProduction).setScale(2, RoundingMode.HALF_UP);
        return new DurationEstimate(standardHours, predictedHours, breakdown, keyPoints, true);
    }
    private record DurationEstimate(BigDecimal standardHours, BigDecimal predictedHours,
                                    List<MesProAiDurationPredictRespVO.ProcessTimeBreakdown> breakdown,
                                    List<String> keyPoints, boolean hasProcessData) {
        private static DurationEstimate empty() {
            return new DurationEstimate(BigDecimal.ZERO, BigDecimal.ZERO,
                    Collections.emptyList(), Collections.emptyList(), false);
        }
    }
@@ -492,9 +809,9 @@
    @Override
    public MesProAiRiskPredictRespVO predictRisk(MesProAiRiskPredictReqVO reqVO) {
        Map<String, Object> data = null;
        String logPrefix;
        try {
            Map<String, Object> data;
            String logPrefix;
            if (reqVO.getMpsId() != null) {
                data = gatherRiskDataFromMps(reqVO.getMpsId());
                logPrefix = "mpsId=" + reqVO.getMpsId();
@@ -506,11 +823,160 @@
            log.info("AI 风险预测: {}, response={}", logPrefix, aiResponse);
            return parseRiskResponse(aiResponse);
        } catch (Exception e) {
            log.warn("AI 风险预测失败,workOrderId={}, mpsId={}", reqVO.getWorkOrderId(), reqVO.getMpsId(), e);
            MesProAiRiskPredictRespVO fallback = new MesProAiRiskPredictRespVO();
            fallback.setReasoning("AI 分析暂时不可用,请稍后重试");
            return fallback;
            log.warn("AI 风险预测失败,workOrderId={}, mpsId={},切换本地规则评估",
                    reqVO.getWorkOrderId(), reqVO.getMpsId(), e);
            return buildRiskFallback(reqVO, data);
        }
    }
    /**
     * AI 不可用时的本地风险评估:IPQC 缺陷率 + 设备状态/保养点检 + 物料可用量 + 关键工序。
     */
    private MesProAiRiskPredictRespVO buildRiskFallback(MesProAiRiskPredictReqVO reqVO,
                                                       Map<String, Object> gathered) {
        MesProAiRiskPredictRespVO respVO = new MesProAiRiskPredictRespVO();
        try {
            Long effectiveWorkOrderId = resolveWorkOrderId(reqVO.getWorkOrderId(), reqVO.getMpsId());
            Map<String, Object> data = effectiveWorkOrderId != null
                    ? gatherRiskData(effectiveWorkOrderId) : gathered;
            List<MesProAiRiskPredictRespVO.RiskItem> risks = new ArrayList<>();
            List<String> keyPoints = new ArrayList<>();
            if (data == null) {
                respVO.setOverallRiskLevel(1);
                respVO.setRisks(Collections.emptyList());
                respVO.setKeyPoints(Collections.emptyList());
                respVO.setReasoning("AI 服务暂不可用,且未获取到工单/MPS 风险数据,无法评估。");
                return respVO;
            }
            // 1. 质量风险
            for (Map<String, Object> qc : asMapList(data.get("过程检验(IPQC)记录"))) {
                BigDecimal critical = parsePercent(qc.get("致命缺陷率"));
                BigDecimal major = parsePercent(qc.get("严重缺陷率"));
                BigDecimal minor = parsePercent(qc.get("轻微缺陷率"));
                String code = StrUtil.blankToDefault((String) qc.get("检验单编号"), "过程检验单");
                if (critical.compareTo(BigDecimal.valueOf(5)) > 0) {
                    risks.add(risk("质量", String.format("%s 致命缺陷率 %s%%,超过 5%% 阈值", code, critical.stripTrailingZeros().toPlainString()),
                            "高", "建议立即停线排查,对已生产批次进行隔离复检"));
                } else if (major.compareTo(BigDecimal.TEN) > 0) {
                    risks.add(risk("质量", String.format("%s 严重缺陷率 %s%%,超过 10%% 阈值", code, major.stripTrailingZeros().toPlainString()),
                            "中", "建议分析缺陷根因并加强过程管控"));
                } else if (critical.compareTo(BigDecimal.ZERO) > 0 || major.compareTo(BigDecimal.ZERO) > 0
                        || minor.compareTo(BigDecimal.ZERO) > 0) {
                    risks.add(risk("质量", String.format("%s 存在不良品(致命 %s%%、严重 %s%%、轻微 %s%%)",
                                    code, critical.stripTrailingZeros().toPlainString(),
                                    major.stripTrailingZeros().toPlainString(),
                                    minor.stripTrailingZeros().toPlainString()),
                            "低", "建议持续跟踪检验数据,关注缺陷率变化趋势"));
                }
            }
            // 2. 设备风险
            for (Map<String, Object> eq : asMapList(data.get("关联设备信息"))) {
                String name = StrUtil.blankToDefault((String) eq.get("设备名称"),
                        String.valueOf(eq.get("设备编号")));
                int status = toInteger(eq.get("设备状态"));
                if (status == 1) {
                    risks.add(risk("设备", name + " 当前处于停机状态", "高",
                            "建议生产前确认停机原因并完成恢复,避免影响开工"));
                } else if (status == 3) {
                    risks.add(risk("设备", name + " 当前处于维护中", "中",
                            "建议确认维护完成时间,必要时调整排产计划"));
                }
                Long maintenanceDays = daysSince(eq.get("上次保养时间"));
                if (maintenanceDays != null && maintenanceDays > 30) {
                    risks.add(risk("设备", String.format("%s 已 %d 天未保养", name, maintenanceDays), "中",
                            "建议在生产前安排保养,降低设备故障风险"));
                }
                Long checkDays = daysSince(eq.get("上次点检时间"));
                if (checkDays != null && checkDays > 14) {
                    risks.add(risk("设备", String.format("%s 已 %d 天未点检", name, checkDays), "低",
                            "建议开工前完成设备点检"));
                }
            }
            // 3. 物料风险
            for (Map<String, Object> material : asMapList(data.get("物料短缺风险"))) {
                BigDecimal required = toBigDecimal(material.get("需求量"));
                BigDecimal available = toBigDecimal(material.get("可用量"));
                BigDecimal shortage = required.subtract(available).max(BigDecimal.ZERO);
                if (shortage.compareTo(BigDecimal.ZERO) <= 0) {
                    continue;
                }
                boolean severe = available.compareTo(required.multiply(BigDecimal.valueOf(0.5))) < 0;
                risks.add(risk("物料", String.format("%s 需求 %s、可用 %s,缺口 %s",
                                material.get("物料名称"), required.stripTrailingZeros().toPlainString(),
                                available.stripTrailingZeros().toPlainString(),
                                shortage.stripTrailingZeros().toPlainString()),
                        severe ? "高" : "中",
                        severe ? "建议紧急采购或调拨,避免影响开工" : "建议尽快补充采购或内部调拨"));
            }
            // 3.1 MPS 未下发时,按产品 BOM 补充物料风险
            if (effectiveWorkOrderId == null && reqVO.getMpsId() != null) {
                List<MaterialDemand> demands = buildMaterialDemandsFromMps(reqVO.getMpsId());
                Map<Long, BigDecimal> stockMap = getAvailableStockMap(
                        demands.stream().map(MaterialDemand::itemId).collect(Collectors.toSet()));
                for (MaterialDemand demand : demands) {
                    BigDecimal available = stockMap.getOrDefault(demand.itemId(), BigDecimal.ZERO);
                    BigDecimal shortage = demand.requiredQuantity().subtract(available).max(BigDecimal.ZERO);
                    if (shortage.compareTo(BigDecimal.ZERO) <= 0) {
                        continue;
                    }
                    boolean severe = available.compareTo(
                            demand.requiredQuantity().multiply(BigDecimal.valueOf(0.5))) < 0;
                    risks.add(risk("物料", String.format("物料 %s 需求 %s、可用 %s,缺口 %s",
                                    resolveItemName(demand.itemId()),
                                    demand.requiredQuantity().stripTrailingZeros().toPlainString(),
                                    available.stripTrailingZeros().toPlainString(),
                                    shortage.stripTrailingZeros().toPlainString()),
                            severe ? "高" : "中",
                            severe ? "建议紧急采购或调拨" : "建议尽快补充采购或内部调拨"));
                }
            }
            // 4. 关键工序关注点
            for (Map<String, Object> proc : asMapList(data.get("关键工序"))) {
                keyPoints.add(String.format("关键工序「%s」(顺序 %s)需重点关注产能与排队情况",
                        proc.get("工序名称"), proc.get("顺序")));
            }
            keyPoints.removeIf(StrUtil::isBlank);
            int overallLevel = risks.stream()
                    .mapToInt(item -> severityRank(item.getSeverity()))
                    .max().orElse(1);
            respVO.setOverallRiskLevel(overallLevel);
            respVO.setRisks(risks);
            respVO.setKeyPoints(keyPoints);
            if (risks.isEmpty()) {
                respVO.setReasoning("AI 大模型暂不可用,已自动切换本地规则引擎:综合 IPQC 缺陷率、设备状态与保养点检、"
                        + "物料可用量及关键工序进行评估,未发现明显风险。");
            } else {
                respVO.setReasoning(String.format(
                        "AI 大模型暂不可用,已自动切换本地规则引擎:综合 IPQC 缺陷率、设备状态与保养点检、"
                                + "物料可用量及关键工序进行评估,共识别 %d 项风险,整体风险等级为%s。",
                        risks.size(), overallLevel == 3 ? "高风险" : (overallLevel == 2 ? "中风险" : "低风险")));
            }
        } catch (Exception e) {
            log.warn("本地风险评估失败,workOrderId={}, mpsId={}",
                    reqVO.getWorkOrderId(), reqVO.getMpsId(), e);
            respVO.setOverallRiskLevel(1);
            respVO.setRisks(Collections.emptyList());
            respVO.setKeyPoints(Collections.emptyList());
            respVO.setReasoning("AI 分析暂时不可用,且本地风险数据核算失败,请稍后重试或检查工单数据。");
        }
        return respVO;
    }
    private MesProAiRiskPredictRespVO.RiskItem risk(String category, String description,
                                                     String severity, String suggestion) {
        MesProAiRiskPredictRespVO.RiskItem item = new MesProAiRiskPredictRespVO.RiskItem();
        item.setCategory(category);
        item.setDescription(description);
        item.setSeverity(severity);
        item.setSuggestion(suggestion);
        return item;
    }
    private Map<String, Object> gatherRiskData(Long workOrderId) {
@@ -703,9 +1169,9 @@
    @Override
    public MesProAiDeliveryPredictRespVO predictDelivery(MesProAiDeliveryPredictReqVO reqVO) {
        Map<String, Object> data = null;
        String logPrefix;
        try {
            Map<String, Object> data;
            String logPrefix;
            if (reqVO.getMpsId() != null) {
                data = gatherDeliveryDataFromMps(reqVO.getMpsId());
                logPrefix = "mpsId=" + reqVO.getMpsId();
@@ -717,11 +1183,133 @@
            log.info("AI 交付预测: {}, response={}", logPrefix, aiResponse);
            return parseDeliveryResponse(aiResponse);
        } catch (Exception e) {
            log.warn("AI 交付预测失败,workOrderId={}, mpsId={}", reqVO.getWorkOrderId(), reqVO.getMpsId(), e);
            MesProAiDeliveryPredictRespVO fallback = new MesProAiDeliveryPredictRespVO();
            fallback.setReasoning("AI 分析暂时不可用,请稍后重试");
            return fallback;
            log.warn("AI 交付预测失败,workOrderId={}, mpsId={},切换本地规则评估",
                    reqVO.getWorkOrderId(), reqVO.getMpsId(), e);
            return buildDeliveryFallback(reqVO, data);
        }
    }
    /**
     * AI 不可用时的本地交付评估:生产进度 + 剩余交期(按 8 小时/天)+ 剩余预测工时 + 物料缺口。
     */
    private MesProAiDeliveryPredictRespVO buildDeliveryFallback(MesProAiDeliveryPredictReqVO reqVO,
                                                               Map<String, Object> gathered) {
        MesProAiDeliveryPredictRespVO respVO = new MesProAiDeliveryPredictRespVO();
        List<String> delayFactors = new ArrayList<>();
        try {
            Long effectiveWorkOrderId = resolveWorkOrderId(reqVO.getWorkOrderId(), reqVO.getMpsId());
            Map<String, Object> data = effectiveWorkOrderId != null
                    ? gatherDeliveryData(effectiveWorkOrderId) : gathered;
            if (data == null) {
                respVO.setOnTime(false);
                respVO.setConfidence(0D);
                respVO.setProgressPercent(BigDecimal.ZERO);
                respVO.setRemainingDays(0);
                respVO.setDelayFactors(Collections.emptyList());
                respVO.setReasoning("AI 服务暂不可用,且未获取到工单/MPS 进度数据,无法评估交付。");
                return respVO;
            }
            BigDecimal progressPercent = toBigDecimal(data.get("生产进度(%)"));
            int remainingDays = toInteger(data.get("距离需求日期剩余天数"));
            respVO.setProgressPercent(progressPercent);
            respVO.setRemainingDays(remainingDays);
            if (progressPercent.compareTo(BigDecimal.valueOf(100)) >= 0) {
                respVO.setOnTime(true);
                respVO.setConfidence(99D);
                respVO.setDelayFactors(Collections.emptyList());
                respVO.setReasoning("AI 大模型暂不可用,已自动切换本地规则引擎:工单已全部完成,可按时交付。");
                return respVO;
            }
            BigDecimal availableHours = BigDecimal.valueOf(Math.max(remainingDays, 0))
                    .multiply(BigDecimal.valueOf(8));
            BigDecimal remainingHours = null;
            if (effectiveWorkOrderId != null) {
                DurationEstimate estimate = computeDurationEstimate(gatherDurationData(effectiveWorkOrderId));
                if (estimate.hasProcessData()) {
                    remainingHours = estimate.predictedHours();
                }
            }
            boolean onTime;
            double confidence;
            if (remainingDays <= 0) {
                onTime = false;
                confidence = 15D;
                delayFactors.add("需求日期已到或已过期,但工单尚未完成");
            } else if (remainingHours == null) {
                // 缺少工序/设备数据时,退化为按进度与交期粗判
                onTime = progressPercent.compareTo(BigDecimal.valueOf(50)) >= 0 || remainingDays >= 7;
                confidence = 55D;
                delayFactors.add("缺少工序/设备效率数据,无法精确计算剩余工时,仅按进度与交期粗判");
            } else {
                BigDecimal requiredWithBuffer = remainingHours.multiply(BigDecimal.valueOf(0.8));
                onTime = availableHours.compareTo(requiredWithBuffer) >= 0;
                BigDecimal ratio = availableHours.compareTo(BigDecimal.ZERO) > 0
                        ? availableHours.divide(remainingHours, 4, RoundingMode.HALF_UP)
                        : BigDecimal.ZERO;
                confidence = ratio.compareTo(BigDecimal.valueOf(1.5)) >= 0 ? 92D
                        : ratio.compareTo(BigDecimal.valueOf(1.1)) >= 0 ? 82D
                        : ratio.compareTo(BigDecimal.ONE) >= 0 ? 70D
                        : ratio.compareTo(BigDecimal.valueOf(0.8)) >= 0 ? 45D : 20D;
                if (availableHours.compareTo(remainingHours) < 0) {
                    delayFactors.add(String.format(
                            "剩余交期 %d 天(按 8 小时/天共 %.1f 小时),预计仍需 %.1f 小时,工期不足",
                            remainingDays, availableHours.doubleValue(), remainingHours.doubleValue()));
                }
            }
            // 任务完成情况
            long finishedTasks = toInteger(data.get("已完成任务数"));
            long totalTasks = toInteger(data.get("总任务数"));
            if (totalTasks > 0 && finishedTasks < totalTasks) {
                delayFactors.add(String.format("仍有 %d 个任务未完成(共 %d 个)",
                        totalTasks - finishedTasks, totalTasks));
            }
            // 物料缺口
            List<MaterialDemand> materialDemands = effectiveWorkOrderId != null
                    ? buildMaterialDemandsFromWorkOrder(effectiveWorkOrderId)
                    : (reqVO.getMpsId() != null ? buildMaterialDemandsFromMps(reqVO.getMpsId())
                    : Collections.emptyList());
            Map<Long, BigDecimal> stockMap = getAvailableStockMap(
                    materialDemands.stream().map(MaterialDemand::itemId).collect(Collectors.toSet()));
            long shortageItems = materialDemands.stream()
                    .filter(demand -> stockMap.getOrDefault(demand.itemId(), BigDecimal.ZERO)
                            .compareTo(demand.requiredQuantity()) < 0)
                    .count();
            if (shortageItems > 0) {
                delayFactors.add(String.format("有 %d 项物料库存不足,可能影响开工", shortageItems));
                if (onTime) {
                    onTime = false;
                    confidence = Math.min(confidence, 45D);
                }
            }
            respVO.setOnTime(onTime);
            respVO.setConfidence(confidence);
            respVO.setDelayFactors(delayFactors);
            respVO.setReasoning(String.format(
                    "AI 大模型暂不可用,已自动切换本地规则引擎:当前生产进度 %s%%,剩余交期 %d 天"
                            + "(按每天 8 小时折算 %.1f 小时)%s。",
                    progressPercent.stripTrailingZeros().toPlainString(), remainingDays,
                    availableHours.doubleValue(),
                    remainingHours != null
                            ? String.format(",预计剩余工时 %.1f 小时", remainingHours.doubleValue())
                            : ",因缺少工序/设备数据未计算剩余工时"));
        } catch (Exception e) {
            log.warn("本地交付评估失败,workOrderId={}, mpsId={}",
                    reqVO.getWorkOrderId(), reqVO.getMpsId(), e);
            respVO.setOnTime(false);
            respVO.setConfidence(0D);
            respVO.setProgressPercent(BigDecimal.ZERO);
            respVO.setRemainingDays(0);
            respVO.setDelayFactors(Collections.emptyList());
            respVO.setReasoning("AI 分析暂时不可用,且本地交付数据核算失败,请稍后重试或检查工单数据。");
        }
        return respVO;
    }
    private Map<String, Object> gatherDeliveryData(Long workOrderId) {
@@ -858,6 +1446,61 @@
    // ==================== Shared Utilities ====================
    @SuppressWarnings("unchecked")
    private List<Map<String, Object>> asMapList(Object value) {
        if (value instanceof List<?> list) {
            return (List<Map<String, Object>>) list;
        }
        return Collections.emptyList();
    }
    private BigDecimal parsePercent(Object value) {
        if (value == null) {
            return BigDecimal.ZERO;
        }
        String text = value.toString().replace("%", "").trim();
        try {
            return new BigDecimal(text);
        } catch (Exception e) {
            return BigDecimal.ZERO;
        }
    }
    /** 计算距今天数;无法解析或“无记录”时返回 null */
    private Long daysSince(Object value) {
        if (value == null) {
            return null;
        }
        String text = value.toString().trim();
        if (StrUtil.isBlank(text) || "无记录".equals(text)) {
            return null;
        }
        try {
            String normalized = text.replace(' ', 'T');
            if (normalized.length() >= 19) {
                normalized = normalized.substring(0, 19);
            }
            LocalDateTime time = normalized.length() > 10
                    ? LocalDateTime.parse(normalized)
                    : LocalDate.parse(normalized).atStartOfDay();
            return ChronoUnit.DAYS.between(time.toLocalDate(), LocalDate.now());
        } catch (Exception e) {
            return null;
        }
    }
    private int severityRank(String severity) {
        if (severity == null) {
            return 1;
        }
        return switch (severity) {
            case "高" -> 3;
            case "中" -> 2;
            case "低" -> 1;
            default -> 1;
        };
    }
    private String extractJson(String aiResponse) {
        int start = aiResponse.indexOf("{");
        int end = aiResponse.lastIndexOf("}");