From 71e87ac75fdeb98efa3005198c238d5e62ab56e2 Mon Sep 17 00:00:00 2001
From: liu <2021943741@qq.com>
Date: 星期一, 19 十月 2026 13:11:03 +0800
Subject: [PATCH] fix(mes): AI 预测在未配置大模型时回退本地规则引擎

---
 yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/service/pro/ai/MesProAiServiceImpl.java |  687 +++++++++++++++++++++++++++++++++++++++++++++++++++++++-
 1 files changed, 665 insertions(+), 22 deletions(-)

diff --git a/yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/service/pro/ai/MesProAiServiceImpl.java b/yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/service/pro/ai/MesProAiServiceImpl.java
index 208b2d9..9fd272d 100644
--- a/yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/service/pro/ai/MesProAiServiceImpl.java
+++ b/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 鐗╂枡棰勬祴澶辫触锛寃orkOrderId={}, mpsId={}", reqVO.getWorkOrderId(), reqVO.getMpsId(), e);
-            MesProAiMaterialPredictRespVO fallback = new MesProAiMaterialPredictRespVO();
-            fallback.setReasoning("AI 鍒嗘瀽鏆傛椂涓嶅彲鐢紝璇风◢鍚庨噸璇�");
-            return fallback;
+            log.warn("AI 鐗╂枡棰勬祴澶辫触锛寃orkOrderId={}, 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锛屾垨鍦ㄣ�孉I 澶фā鍨� - 妯″瀷绠$悊銆嶄腑閰嶇疆鍙敤鐨勫璇濇ā鍨嬨��");
+                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("鏈湴鐗╂枡鏍哥畻澶辫触锛寃orkOrderId={}, 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 鏃堕暱棰勬祴澶辫触锛寃orkOrderId={}, mpsId={}", reqVO.getWorkOrderId(), reqVO.getMpsId(), e);
-            MesProAiDurationPredictRespVO fallback = new MesProAiDurationPredictRespVO();
-            fallback.setReasoning("AI 鍒嗘瀽鏆傛椂涓嶅彲鐢紝璇风◢鍚庨噸璇�");
-            return fallback;
+            log.warn("AI 鏃堕暱棰勬祴澶辫触锛寃orkOrderId={}, 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 娌℃湁鍙敤鐨勫伐搴忔垨璁惧鏁版嵁锛屾棤娉曚及绠楃敓浜ф椂闀裤��"
+                        + "璇峰厛缁存姢宸ヨ壓璺嚎骞跺畬鎴愭帓浜э紝鎴栧湪銆孉I 澶фā鍨� - 妯″瀷绠$悊銆嶄腑閰嶇疆鍙敤鐨勫璇濇ā鍨嬨��");
+                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("鏈湴鏃堕暱浼扮畻澶辫触锛寃orkOrderId={}, 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 椋庨櫓棰勬祴澶辫触锛寃orkOrderId={}, mpsId={}", reqVO.getWorkOrderId(), reqVO.getMpsId(), e);
-            MesProAiRiskPredictRespVO fallback = new MesProAiRiskPredictRespVO();
-            fallback.setReasoning("AI 鍒嗘瀽鏆傛椂涓嶅彲鐢紝璇风◢鍚庨噸璇�");
-            return fallback;
+            log.warn("AI 椋庨櫓棰勬祴澶辫触锛寃orkOrderId={}, 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("鏈湴椋庨櫓璇勪及澶辫触锛寃orkOrderId={}, 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 浜や粯棰勬祴澶辫触锛寃orkOrderId={}, mpsId={}", reqVO.getWorkOrderId(), reqVO.getMpsId(), e);
-            MesProAiDeliveryPredictRespVO fallback = new MesProAiDeliveryPredictRespVO();
-            fallback.setReasoning("AI 鍒嗘瀽鏆傛椂涓嶅彲鐢紝璇风◢鍚庨噸璇�");
-            return fallback;
+            log.warn("AI 浜や粯棰勬祴澶辫触锛寃orkOrderId={}, 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("鏈湴浜や粯璇勪及澶辫触锛寃orkOrderId={}, 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("}");

--
Gitblit v1.9.3