| | |
| | | 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; |
| | |
| | | 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; |
| | |
| | | import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; |
| | | import jakarta.annotation.Resource; |
| | | import lombok.extern.slf4j.Slf4j; |
| | | import org.springframework.beans.factory.annotation.Autowired; |
| | | import org.springframework.stereotype.Service; |
| | | |
| | | import java.math.BigDecimal; |
| | |
| | | @Resource |
| | | private MesProWorkOrderBomMapper workOrderBomMapper; |
| | | @Resource |
| | | private MesMdProductBomMapper productBomMapper; |
| | | @Resource |
| | | private MesProWorkOrderProcessService workOrderProcessService; |
| | | @Resource |
| | | private MesProTaskMapper taskMapper; |
| | |
| | | private MdmItemApi mdmItemApi; |
| | | @Resource |
| | | private CrmCustomerApi crmCustomerApi; |
| | | @Resource |
| | | @Autowired(required = false) |
| | | private AiChatApi aiChatApi; |
| | | |
| | | // ==================== 1. 物料短缺预测 ==================== |
| | |
| | | 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) { |
| | |
| | | |
| | | @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(); |
| | |
| | | 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); |
| | | } |
| | | } |
| | | |
| | |
| | | |
| | | @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(); |
| | |
| | | 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) { |
| | |
| | | |
| | | @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(); |
| | |
| | | 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) { |
| | |
| | | |
| | | // ==================== 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("}"); |