package cn.iocoder.yudao.module.mes.service.pro.ai; import cn.hutool.core.collection.CollUtil; import cn.hutool.core.util.StrUtil; import cn.hutool.json.JSONUtil; import cn.iocoder.yudao.module.ai.api.chat.AiChatApi; import cn.iocoder.yudao.module.crm.api.customer.CrmCustomerApi; import cn.iocoder.yudao.module.crm.api.customer.dto.CrmCustomerRespDTO; import cn.iocoder.yudao.module.mdm.api.item.MdmItemApi; 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.pro.process.MesProProcessDO; import cn.iocoder.yudao.module.mes.dal.dataobject.pro.task.MesProTaskDO; import cn.iocoder.yudao.module.mes.dal.dataobject.pro.task.MesProTaskIssueDO; import cn.iocoder.yudao.module.mes.dal.dataobject.pro.workorder.MesProWorkOrderBomDO; import cn.iocoder.yudao.module.mes.dal.dataobject.pro.workorder.MesProWorkOrderDO; import cn.iocoder.yudao.module.mes.dal.dataobject.pro.workorder.MesProWorkOrderProcessDO; 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 cn.iocoder.yudao.module.mes.dal.mysql.pro.task.MesProTaskIssueMapper; import cn.iocoder.yudao.module.mes.dal.mysql.pro.task.MesProTaskMapper; import cn.iocoder.yudao.module.mes.dal.mysql.pro.workorder.MesProWorkOrderBomMapper; import cn.iocoder.yudao.module.mes.dal.mysql.qc.ipqc.MesQcIpqcMapper; import cn.iocoder.yudao.module.mes.dal.mysql.wm.materialstock.MesWmMaterialStockMapper; import cn.iocoder.yudao.module.mes.service.pro.mps.MesProMpsService; import cn.iocoder.yudao.module.mes.service.pro.process.MesProProcessService; import cn.iocoder.yudao.module.mes.service.pro.workorder.MesProWorkOrderProcessService; import cn.iocoder.yudao.module.mes.service.pro.workorder.MesProWorkOrderService; 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; import java.math.RoundingMode; import java.time.LocalDate; import java.time.LocalDateTime; import java.time.temporal.ChronoUnit; import java.util.*; import java.util.stream.Collectors; import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception; import static cn.iocoder.yudao.module.mes.enums.ErrorCodeConstants.PRO_WORK_ORDER_NOT_EXISTS; @Slf4j @Service public class MesProAiServiceImpl implements MesProAiService { // ==================== System Prompts ==================== private static final String SYSTEM_PROMPT_MATERIAL = """ 你是一位资深的制造业物料管理专家,擅长分析生产工单的物料齐套性和短缺风险。 你需要综合分析以下信息,给出专业的物料预测建议: ## 分析维度 1. BOM 需求 vs 库存可用量:对比每个物料的预计使用量与当前库存可用量 2. 库存可用量 = 在库数量 - 冻结数量 - 已占用数量 3. 已投料情况:已经发放到工位的物料是否充足 4. 风险评估:对有短缺风险的物料标注风险等级 ## 风险等级定义 - 充足:可用量 >= 需求量 - 紧张:可用量 >= 需求量的50% 但 < 需求量 - 短缺:可用量 < 需求量的50% 或可用量为0 ## 输出格式 请按以下JSON格式返回(不要包含其他内容): {"riskLevel": 1, "reasoning": "综合分析...", "riskItems": [{"itemCode": "...", "itemName": "...", "requiredQuantity": 100.0, "availableQuantity": 50.0, "shortageQuantity": 50.0, "riskLevel": "紧张", "suggestion": "建议紧急采购或调拨"}]} riskLevel 取值:1=低风险 2=中风险 3=高风险 """; private static final String SYSTEM_PROMPT_DURATION = """ 你是一位资深的生产计划专家,擅长预测生产工单的实际生产时长。 你需要综合分析以下信息,给出专业的时长预测建议: ## 分析依据 1. 标准工时:工艺路线设定的标准准备时间、等待时间、生产时间 2. 设备效率:关联设备的每小时产量,传送履带倍率 3. 生产数量:工单计划数量 4. 实际历史数据:已完成工序的实际生产时长 5. 计算公式:预测时长 = 准备时间 + 等待时间 + (计划数量 / (设备效率 * 传送履带系数))(效率单位为每小时产量) ## 输出格式 请按以下JSON格式返回(不要包含其他内容): {"predictedTotalHours": 48.5, "standardTotalHours": 40.0, "reasoning": "...", "keyPoints": ["关键工序A是瓶颈"], "processBreakdown": [{"processName": "...", "sort": 1, "standardPrepareTime": 30, "standardWaitTime": 15, "standardProductionTime": 8.0, "predictedProductionTime": 10.5}]} predictedTotalHours 和 standardTotalHours 为小数 """; private static final String SYSTEM_PROMPT_RISK = """ 你是一位资深的制造风险管理专家,擅长识别生产过程中的各类风险。 你需要综合分析以下信息,给出专业的风险评估: ## 分析维度 1. 质量风险:过程检验(IPQC)的缺陷率数据(致命/严重/轻微缺陷率) 2. 设备风险:设备状态、上次保养时间、上次点检时间、生产效率 3. 物料风险:物料库存可用量与需求量的对比 4. 工艺瓶颈:关键工序的设备产能、任务排队情况 ## 风险等级定义 - 高:存在致命缺陷率>5% 或 设备故障 或 关键物料短缺>50% 或 关键工序严重积压 - 中:存在严重缺陷率>10% 或 设备长期未保养 或 物料紧张 - 低:各项指标正常 ## 输出格式 请按以下JSON格式返回(不要包含其他内容): {"overallRiskLevel": 2, "reasoning": "...", "keyPoints": ["关注点1"], "risks": [{"category": "设备", "description": "设备A上次保养距今已超30天", "severity": "中", "suggestion": "建议生产前保养"}]} overallRiskLevel 取值:1=低风险 2=中风险 3=高风险 riskLevel 取值:充足/紧张/短缺 severity 取值:高/中/低 category 取值:质量/设备/物料/工艺瓶颈 """; private static final String SYSTEM_PROMPT_DELIVERY = """ 你是一位资深的订单交付管理专家,擅长评估工单是否能按时交付。 你需要综合分析以下信息,给出专业的交付评估: ## 分析依据 1. 当前进度:已生产数量 / 计划数量的百分比 2. 剩余时间:距离需求日期的天数(工作日按每天8小时计算) 3. 预计剩余工时:根据工艺路线和设备效率预测的剩余生产时长 4. 生产速度:根据已完成任务的实际生产速率 5. 任务状态:各工序的完成情况 ## 判定逻辑 - 按时交付:剩余天数 * 8小时 >= 预计剩余工时,且进度正常 - 有延期风险:剩余天数 * 8小时 >= 预计剩余工时的80%,但不足100% - 无法按时交付:剩余天数 * 8小时 < 预计剩余工时的80%,或关键工序卡阻 ## 输出格式 请按以下JSON格式返回(不要包含其他内容): {"onTime": true, "confidence": 85.5, "reasoning": "...", "delayFactors": [], "progressPercent": 60.0, "remainingDays": 10} onTime: true=可按时交付 false=无法按时交付 confidence: 0-100的数字,表示评估置信度 """; // ==================== Dependencies ==================== @Resource private MesProWorkOrderService workOrderService; @Resource private MesProWorkOrderBomMapper workOrderBomMapper; @Resource private MesMdProductBomMapper productBomMapper; @Resource private MesProWorkOrderProcessService workOrderProcessService; @Resource private MesProTaskMapper taskMapper; @Resource private MesProTaskIssueMapper taskIssueMapper; @Resource private MesProFeedbackMapper feedbackMapper; @Resource private MesQcIpqcMapper ipqcMapper; @Resource private MesWmMaterialStockMapper materialStockMapper; @Resource private MesMdWorkstationMachineMapper workstationMachineMapper; @Resource private MesDvMachineryMapper machineryMapper; @Resource private MesProMpsMapper mpsMapper; @Resource private MesProMpsService mpsService; @Resource private MesProProcessService processService; @Resource private MdmItemApi mdmItemApi; @Resource private CrmCustomerApi crmCustomerApi; @Autowired(required = false) private AiChatApi aiChatApi; // ==================== 1. 物料短缺预测 ==================== @Override public MesProAiMaterialPredictRespVO predictMaterial(MesProAiMaterialPredictReqVO reqVO) { try { Map data; String logPrefix; if (reqVO.getMpsId() != null) { data = gatherMaterialDataFromMps(reqVO.getMpsId()); logPrefix = "mpsId=" + reqVO.getMpsId(); } else { data = gatherMaterialData(reqVO.getWorkOrderId()); logPrefix = "workOrderId=" + reqVO.getWorkOrderId(); } String aiResponse = aiChatApi.chat(SYSTEM_PROMPT_MATERIAL, JSONUtil.toJsonPrettyStr(data)); log.info("AI 物料预测: {}, response={}", logPrefix, aiResponse); return parseMaterialResponse(aiResponse); } catch (Exception e) { 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 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 stockMap = getAvailableStockMap( demands.stream().map(MaterialDemand::itemId).collect(Collectors.toSet())); List 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 buildMaterialDemandsFromWorkOrder(Long workOrderId) { if (workOrderId == null) { return Collections.emptyList(); } List bomList = workOrderBomMapper.selectListByWorkOrderId(workOrderId); if (CollUtil.isEmpty(bomList)) { return Collections.emptyList(); } Map 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 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 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 productBomList = productBomMapper.selectByItemId(mps.getProductId()); if (CollUtil.isEmpty(productBomList)) { return Collections.emptyList(); } Map 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 toMaterialDemands(Map demandByItem) { List demands = new ArrayList<>(demandByItem.size()); demandByItem.forEach((itemId, quantity) -> demands.add(new MaterialDemand(itemId, quantity))); return demands; } private Map getAvailableStockMap(Collection itemIds) { if (CollUtil.isEmpty(itemIds)) { return Collections.emptyMap(); } List stocks = materialStockMapper.selectList( new LambdaQueryWrapper() .in(MesWmMaterialStockDO::getItemId, itemIds)); Map 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 riskItems) { return riskItems.stream().filter(item -> "充足".equals(item.getRiskLevel())).count(); } private record MaterialDemand(Long itemId, BigDecimal requiredQuantity) { } private Map gatherMaterialData(Long workOrderId) { Map data = new LinkedHashMap<>(); MesProWorkOrderDO workOrder = workOrderService.getWorkOrder(workOrderId); if (workOrder == null) throw exception(PRO_WORK_ORDER_NOT_EXISTS); data.put("工单编号", workOrder.getCode()); data.put("工单名称", workOrder.getName()); data.put("计划数量", workOrder.getQuantity()); data.put("已生产数量", workOrder.getQuantityProduced()); fillItemInfo(data, workOrder.getProductId()); // BOM 物料需求 List bomList = workOrderBomMapper.selectListByWorkOrderId(workOrderId); if (CollUtil.isNotEmpty(bomList)) { List> materials = new ArrayList<>(); for (MesProWorkOrderBomDO bom : bomList) { Map item = new LinkedHashMap<>(); item.put("物料ID", bom.getItemId()); String itemName = resolveItemName(bom.getItemId()); item.put("物料名称", itemName); item.put("需求量", bom.getQuantity()); // 查询库存可用量 List stocks = materialStockMapper.selectList( new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper() .eq(MesWmMaterialStockDO::getItemId, bom.getItemId()) .ne(MesWmMaterialStockDO::getQuantity, BigDecimal.ZERO)); BigDecimal available = BigDecimal.ZERO; if (CollUtil.isNotEmpty(stocks)) { for (MesWmMaterialStockDO stock : stocks) { BigDecimal frozen = stock.getFrozenQuantity() != null ? stock.getFrozenQuantity() : BigDecimal.ZERO; BigDecimal reserved = stock.getReservedQuantity() != null ? stock.getReservedQuantity() : BigDecimal.ZERO; available = available.add(stock.getQuantity().subtract(frozen).subtract(reserved)); } } item.put("库存可用量", available.compareTo(BigDecimal.ZERO) > 0 ? available : BigDecimal.ZERO); materials.add(item); } data.put("物料需求清单", materials); } // 已投料情况 List tasks = taskMapper.selectListByWorkOrderId(workOrderId); if (CollUtil.isNotEmpty(tasks)) { List> issuedMaterials = new ArrayList<>(); for (MesProTaskDO task : tasks) { List issues = taskIssueMapper.selectListByTaskId(task.getId()); for (MesProTaskIssueDO issue : issues) { Map issued = new LinkedHashMap<>(); issued.put("物料ID", issue.getItemId()); issued.put("已投料数量", issue.getIssuedQuantity()); issued.put("已使用数量", issue.getUsedQuantity()); issued.put("可用数量", issue.getAvailableQuantity()); issuedMaterials.add(issued); } } if (!issuedMaterials.isEmpty()) { data.put("已投料记录", issuedMaterials); } } return data; } private Map gatherMaterialDataFromMps(Long mpsId) { Map data = new LinkedHashMap<>(); MesProMpsDO mps = mpsService.getMps(mpsId); if (mps == null) throw exception(PRO_WORK_ORDER_NOT_EXISTS); data.put("MPS 编号", mps.getCode()); data.put("来源", "MPS 未下发,基于产品信息预测"); data.put("计划数量", mps.getQuantity()); data.put("已生产数量", mps.getQuantityProduced() != null ? mps.getQuantityProduced() : BigDecimal.ZERO); fillItemInfo(data, mps.getProductId()); // 查询产品在库物料库存(通用,非工单特定 BOM) List stocks = materialStockMapper.selectList( new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper() .ne(MesWmMaterialStockDO::getQuantity, BigDecimal.ZERO)); if (CollUtil.isNotEmpty(stocks)) { List> materials = new ArrayList<>(); for (MesWmMaterialStockDO stock : stocks) { Map item = new LinkedHashMap<>(); item.put("物料ID", stock.getItemId()); item.put("物料名称", resolveItemName(stock.getItemId())); item.put("库存可用量", stock.getQuantity()); materials.add(item); } data.put("当前库存物料", materials); } else { data.put("提示", "暂无库存数据,请参考产品编码评估物料需求"); } return data; } private MesProAiMaterialPredictRespVO parseMaterialResponse(String aiResponse) { MesProAiMaterialPredictRespVO respVO = new MesProAiMaterialPredictRespVO(); try { String json = extractJson(aiResponse); Map parsed = JSONUtil.toBean(json, Map.class); Object riskLevel = parsed.get("riskLevel"); if (riskLevel instanceof Number num) respVO.setRiskLevel(num.intValue()); respVO.setReasoning((String) parsed.get("reasoning")); @SuppressWarnings("unchecked") List> items = (List>) parsed.get("riskItems"); if (CollUtil.isNotEmpty(items)) { respVO.setRiskItems(items.stream().map(m -> { MesProAiMaterialPredictRespVO.RiskItem ri = new MesProAiMaterialPredictRespVO.RiskItem(); ri.setItemCode((String) m.get("itemCode")); ri.setItemName((String) m.get("itemName")); ri.setRequiredQuantity(toBigDecimal(m.get("requiredQuantity"))); ri.setAvailableQuantity(toBigDecimal(m.get("availableQuantity"))); ri.setShortageQuantity(toBigDecimal(m.get("shortageQuantity"))); ri.setRiskLevel((String) m.get("riskLevel")); ri.setSuggestion((String) m.get("suggestion")); return ri; }).collect(Collectors.toList())); } } catch (Exception e) { log.warn("解析 AI 物料预测响应失败: {}", aiResponse, e); respVO.setReasoning(aiResponse); } return respVO; } // ==================== 2. 生产时长预测 ==================== @Override public MesProAiDurationPredictRespVO predictDuration(MesProAiDurationPredictReqVO reqVO) { Map data = null; String logPrefix; try { if (reqVO.getMpsId() != null) { data = gatherDurationDataFromMps(reqVO.getMpsId()); logPrefix = "mpsId=" + reqVO.getMpsId(); } else { data = gatherDurationData(reqVO.getWorkOrderId()); logPrefix = "workOrderId=" + reqVO.getWorkOrderId(); } String aiResponse = aiChatApi.chat(SYSTEM_PROMPT_DURATION, JSONUtil.toJsonPrettyStr(data)); log.info("AI 时长预测: {}, response={}", logPrefix, aiResponse); return parseDurationResponse(aiResponse); } catch (Exception e) { log.warn("AI 时长预测失败,workOrderId={}, mpsId={},切换本地规则估算", reqVO.getWorkOrderId(), reqVO.getMpsId(), e); return buildDurationFallback(reqVO, data); } } /** * AI 不可用时的本地时长估算:标准准备/等待时间 + 计划数量 / 设备效率,生产时间预留 10% 缓冲。 */ private MesProAiDurationPredictRespVO buildDurationFallback(MesProAiDurationPredictReqVO reqVO, Map gathered) { MesProAiDurationPredictRespVO respVO = new MesProAiDurationPredictRespVO(); try { Map 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 data) { if (data == null) { return DurationEstimate.empty(); } Object rawProcesses = data.get("工序明细"); List> processes = rawProcesses instanceof List ? (List>) rawProcesses : Collections.emptyList(); if (CollUtil.isEmpty(processes)) { return DurationEstimate.empty(); } BigDecimal prepareWaitHours = toBigDecimal(data.get("标准总准备+等待时间(小时)")); BigDecimal standardProduction = BigDecimal.ZERO; BigDecimal predictedProduction = BigDecimal.ZERO; List breakdown = new ArrayList<>(processes.size()); List keyPoints = new ArrayList<>(); int missingEfficiency = 0; for (Map 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 breakdown, List keyPoints, boolean hasProcessData) { private static DurationEstimate empty() { return new DurationEstimate(BigDecimal.ZERO, BigDecimal.ZERO, Collections.emptyList(), Collections.emptyList(), false); } } private Map gatherDurationData(Long workOrderId) { Map data = new LinkedHashMap<>(); MesProWorkOrderDO workOrder = workOrderService.getWorkOrder(workOrderId); if (workOrder == null) throw exception(PRO_WORK_ORDER_NOT_EXISTS); data.put("工单编号", workOrder.getCode()); data.put("计划数量", workOrder.getQuantity()); data.put("已生产数量", workOrder.getQuantityProduced()); fillItemInfo(data, workOrder.getProductId()); // 工序信息 List processes = workOrderProcessService.getWorkOrderProcessListByWorkOrderId(workOrderId); List tasks = taskMapper.selectListByWorkOrderId(workOrderId); Map taskByProcessId = new HashMap<>(); if (CollUtil.isNotEmpty(tasks)) { for (MesProTaskDO task : tasks) { if (task.getProcessId() != null) { taskByProcessId.put(task.getProcessId(), task); } } } List> processList = new ArrayList<>(); BigDecimal standardTotalHours = BigDecimal.ZERO; for (MesProWorkOrderProcessDO proc : processes) { Map item = new LinkedHashMap<>(); String processName = resolveProcessName(proc.getProcessId()); item.put("工序名称", processName); item.put("顺序", proc.getSort()); item.put("标准准备时间(分钟)", proc.getPrepareTime() != null ? proc.getPrepareTime() : 0); item.put("标准等待时间(分钟)", proc.getWaitTime() != null ? proc.getWaitTime() : 0); item.put("是否关键工序", proc.getKeyFlag() != null && proc.getKeyFlag()); item.put("是否质检工序", proc.getCheckFlag() != null && proc.getCheckFlag()); // 设备效率 MesProTaskDO task = taskByProcessId.get(proc.getProcessId()); if (task != null && task.getWorkstationId() != null) { BigDecimal totalEfficiency = getWorkstationEfficiency(task.getWorkstationId()); item.put("设备总效率(件/小时)", totalEfficiency); if (totalEfficiency.compareTo(BigDecimal.ZERO) > 0) { BigDecimal productionHours = workOrder.getQuantity() .subtract(workOrder.getQuantityProduced() != null ? workOrder.getQuantityProduced() : BigDecimal.ZERO) .divide(totalEfficiency, 4, RoundingMode.HALF_UP); item.put("预计生产时间(小时)", productionHours); } item.put("任务状态", task.getStatus()); if (task.getDuration() != null) item.put("已用时长(小时)", task.getDuration()); } else { item.put("设备总效率(件/小时)", "未排产无设备数据"); } // 标准时间 int prepareMin = proc.getPrepareTime() != null ? proc.getPrepareTime() : 0; int waitMin = proc.getWaitTime() != null ? proc.getWaitTime() : 0; BigDecimal standardHours = BigDecimal.valueOf(prepareMin + waitMin).divide(BigDecimal.valueOf(60), 4, RoundingMode.HALF_UP); standardTotalHours = standardTotalHours.add(standardHours); item.put("标准准备+等待时间(小时)", standardHours); processList.add(item); } data.put("工序明细", processList); data.put("标准总准备+等待时间(小时)", standardTotalHours); return data; } private BigDecimal getWorkstationEfficiency(Long workstationId) { BigDecimal totalEfficiency = BigDecimal.ZERO; List machines = workstationMachineMapper.selectListByWorkstationId(workstationId); if (CollUtil.isNotEmpty(machines)) { for (MesMdWorkstationMachineDO wm : machines) { MesDvMachineryDO machinery = machineryMapper.selectById(wm.getMachineryId()); if (machinery != null && machinery.getProductionEfficiency() != null) { BigDecimal efficiency = machinery.getProductionEfficiency(); if (machinery.getConveyorBelt() != null && machinery.getConveyorBelt() > 0) { efficiency = efficiency.multiply(BigDecimal.valueOf(machinery.getConveyorBelt())); } totalEfficiency = totalEfficiency.add(efficiency); } } } return totalEfficiency; } private Map gatherDurationDataFromMps(Long mpsId) { Map data = new LinkedHashMap<>(); MesProMpsDO mps = mpsService.getMps(mpsId); if (mps == null) throw exception(PRO_WORK_ORDER_NOT_EXISTS); data.put("MPS 编号", mps.getCode()); data.put("来源", "MPS 未下发,基于产品信息估算"); data.put("计划数量", mps.getQuantity()); data.put("已生产数量", mps.getQuantityProduced() != null ? mps.getQuantityProduced() : BigDecimal.ZERO); data.put("需求日期", mps.getRequestDate() != null ? mps.getRequestDate().toString() : "未设置"); fillItemInfo(data, mps.getProductId()); data.put("提示", "MPS 未关联工单工序,请基于产品编码和经验估算生产时长"); return data; } private MesProAiDurationPredictRespVO parseDurationResponse(String aiResponse) { MesProAiDurationPredictRespVO respVO = new MesProAiDurationPredictRespVO(); try { String json = extractJson(aiResponse); Map parsed = JSONUtil.toBean(json, Map.class); respVO.setPredictedTotalHours(toBigDecimal(parsed.get("predictedTotalHours"))); respVO.setStandardTotalHours(toBigDecimal(parsed.get("standardTotalHours"))); respVO.setReasoning((String) parsed.get("reasoning")); @SuppressWarnings("unchecked") List keyPoints = (List) parsed.get("keyPoints"); respVO.setKeyPoints(keyPoints); @SuppressWarnings("unchecked") List> breakdown = (List>) parsed.get("processBreakdown"); if (CollUtil.isNotEmpty(breakdown)) { respVO.setProcessBreakdown(breakdown.stream().map(m -> { MesProAiDurationPredictRespVO.ProcessTimeBreakdown pt = new MesProAiDurationPredictRespVO.ProcessTimeBreakdown(); pt.setProcessName((String) m.get("processName")); pt.setSort(toInteger(m.get("sort"))); pt.setStandardPrepareTime(toInteger(m.get("standardPrepareTime"))); pt.setStandardWaitTime(toInteger(m.get("standardWaitTime"))); pt.setStandardProductionTime(toBigDecimal(m.get("standardProductionTime"))); pt.setPredictedProductionTime(toBigDecimal(m.get("predictedProductionTime"))); return pt; }).collect(Collectors.toList())); } } catch (Exception e) { log.warn("解析 AI 时长预测响应失败: {}", aiResponse, e); respVO.setReasoning(aiResponse); } return respVO; } // ==================== 3. 生产风险预测 ==================== @Override public MesProAiRiskPredictRespVO predictRisk(MesProAiRiskPredictReqVO reqVO) { Map data = null; String logPrefix; try { if (reqVO.getMpsId() != null) { data = gatherRiskDataFromMps(reqVO.getMpsId()); logPrefix = "mpsId=" + reqVO.getMpsId(); } else { data = gatherRiskData(reqVO.getWorkOrderId()); logPrefix = "workOrderId=" + reqVO.getWorkOrderId(); } String aiResponse = aiChatApi.chat(SYSTEM_PROMPT_RISK, JSONUtil.toJsonPrettyStr(data)); log.info("AI 风险预测: {}, response={}", logPrefix, aiResponse); return parseRiskResponse(aiResponse); } catch (Exception e) { log.warn("AI 风险预测失败,workOrderId={}, mpsId={},切换本地规则评估", reqVO.getWorkOrderId(), reqVO.getMpsId(), e); return buildRiskFallback(reqVO, data); } } /** * AI 不可用时的本地风险评估:IPQC 缺陷率 + 设备状态/保养点检 + 物料可用量 + 关键工序。 */ private MesProAiRiskPredictRespVO buildRiskFallback(MesProAiRiskPredictReqVO reqVO, Map gathered) { MesProAiRiskPredictRespVO respVO = new MesProAiRiskPredictRespVO(); try { Long effectiveWorkOrderId = resolveWorkOrderId(reqVO.getWorkOrderId(), reqVO.getMpsId()); Map data = effectiveWorkOrderId != null ? gatherRiskData(effectiveWorkOrderId) : gathered; List risks = new ArrayList<>(); List 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 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 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 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 demands = buildMaterialDemandsFromMps(reqVO.getMpsId()); Map 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 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 gatherRiskData(Long workOrderId) { Map data = new LinkedHashMap<>(); MesProWorkOrderDO workOrder = workOrderService.getWorkOrder(workOrderId); if (workOrder == null) throw exception(PRO_WORK_ORDER_NOT_EXISTS); data.put("工单编号", workOrder.getCode()); data.put("工单名称", workOrder.getName()); data.put("计划数量", workOrder.getQuantity()); data.put("已生产数量", workOrder.getQuantityProduced()); fillItemInfo(data, workOrder.getProductId()); // 质量风险 - IPQC List ipqcList = ipqcMapper.selectList( new LambdaQueryWrapper() .eq(MesQcIpqcDO::getWorkOrderId, workOrderId)); if (CollUtil.isNotEmpty(ipqcList)) { List> qcData = ipqcList.stream().map(ipqc -> { Map item = new LinkedHashMap<>(); item.put("检验单编号", ipqc.getCode()); item.put("致命缺陷率", ipqc.getCriticalRate() != null ? ipqc.getCriticalRate() + "%" : "0%"); item.put("严重缺陷率", ipqc.getMajorRate() != null ? ipqc.getMajorRate() + "%" : "0%"); item.put("轻微缺陷率", ipqc.getMinorRate() != null ? ipqc.getMinorRate() + "%" : "0%"); item.put("致命缺陷数", ipqc.getCriticalQuantity() != null ? ipqc.getCriticalQuantity() : 0); item.put("严重缺陷数", ipqc.getMajorQuantity() != null ? ipqc.getMajorQuantity() : 0); item.put("轻微缺陷数", ipqc.getMinorQuantity() != null ? ipqc.getMinorQuantity() : 0); item.put("检验结果", ipqc.getCheckResult()); return item; }).collect(Collectors.toList()); data.put("过程检验(IPQC)记录", qcData); } // 设备风险 List tasks = taskMapper.selectListByWorkOrderId(workOrderId); if (CollUtil.isNotEmpty(tasks)) { Set machineryIds = new HashSet<>(); for (MesProTaskDO task : tasks) { if (task.getWorkstationId() != null) { List wms = workstationMachineMapper.selectListByWorkstationId(task.getWorkstationId()); for (MesMdWorkstationMachineDO wm : wms) { machineryIds.add(wm.getMachineryId()); } } } if (!machineryIds.isEmpty()) { List> equipmentData = new ArrayList<>(); for (Long mid : machineryIds) { MesDvMachineryDO machinery = machineryMapper.selectById(mid); if (machinery != null) { Map eq = new LinkedHashMap<>(); eq.put("设备编号", machinery.getCode()); eq.put("设备名称", machinery.getName()); eq.put("设备状态", machinery.getStatus()); eq.put("生产效率(件/小时)", machinery.getProductionEfficiency()); eq.put("传送履带倍数", machinery.getConveyorBelt()); eq.put("上次保养时间", machinery.getLastMaintenTime() != null ? machinery.getLastMaintenTime().toString() : "无记录"); eq.put("上次点检时间", machinery.getLastCheckTime() != null ? machinery.getLastCheckTime().toString() : "无记录"); equipmentData.add(eq); } } data.put("关联设备信息", equipmentData); } } // 物料风险(轻量级:仅标记可用量 < 20%的物料) List bomList = workOrderBomMapper.selectListByWorkOrderId(workOrderId); if (CollUtil.isNotEmpty(bomList)) { List> materialRisks = new ArrayList<>(); for (MesProWorkOrderBomDO bom : bomList) { List stocks = materialStockMapper.selectList( new LambdaQueryWrapper() .eq(MesWmMaterialStockDO::getItemId, bom.getItemId()) .ne(MesWmMaterialStockDO::getQuantity, BigDecimal.ZERO)); BigDecimal available = BigDecimal.ZERO; if (CollUtil.isNotEmpty(stocks)) { for (MesWmMaterialStockDO stock : stocks) { BigDecimal frozen = stock.getFrozenQuantity() != null ? stock.getFrozenQuantity() : BigDecimal.ZERO; BigDecimal reserved = stock.getReservedQuantity() != null ? stock.getReservedQuantity() : BigDecimal.ZERO; available = available.add(stock.getQuantity().subtract(frozen).subtract(reserved)); } } if (bom.getQuantity() != null && bom.getQuantity().compareTo(BigDecimal.ZERO) > 0 && available.compareTo(bom.getQuantity().multiply(BigDecimal.valueOf(0.2))) < 0) { Map risk = new LinkedHashMap<>(); risk.put("物料ID", bom.getItemId()); risk.put("物料名称", resolveItemName(bom.getItemId())); risk.put("需求量", bom.getQuantity()); risk.put("可用量", available); risk.put("缺口比例", available.compareTo(BigDecimal.ZERO) > 0 ? BigDecimal.ONE.subtract(available.divide(bom.getQuantity(), 2, RoundingMode.HALF_UP)).multiply(BigDecimal.valueOf(100)) + "%" : "100%"); materialRisks.add(risk); } } if (!materialRisks.isEmpty()) { data.put("物料短缺风险", materialRisks); } } // 工艺瓶颈 - 关键工序信息 List processes = workOrderProcessService.getWorkOrderProcessListByWorkOrderId(workOrderId); if (CollUtil.isNotEmpty(processes)) { List> keyProcesses = new ArrayList<>(); for (MesProWorkOrderProcessDO proc : processes) { if (proc.getKeyFlag() != null && proc.getKeyFlag()) { Map kp = new LinkedHashMap<>(); kp.put("工序名称", resolveProcessName(proc.getProcessId())); kp.put("顺序", proc.getSort()); kp.put("标准准备时间(分钟)", proc.getPrepareTime()); kp.put("标准等待时间(分钟)", proc.getWaitTime()); keyProcesses.add(kp); } } if (!keyProcesses.isEmpty()) { data.put("关键工序", keyProcesses); } } return data; } private Map gatherRiskDataFromMps(Long mpsId) { Map data = new LinkedHashMap<>(); MesProMpsDO mps = mpsService.getMps(mpsId); if (mps == null) throw exception(PRO_WORK_ORDER_NOT_EXISTS); data.put("MPS 编号", mps.getCode()); data.put("来源", "MPS 未下发,风险评估基于产品信息"); data.put("计划数量", mps.getQuantity()); data.put("已生产数量", mps.getQuantityProduced() != null ? mps.getQuantityProduced() : BigDecimal.ZERO); data.put("需求日期", mps.getRequestDate() != null ? mps.getRequestDate().toString() : "未设置"); data.put("下发日期", mps.getIssueDate() != null ? mps.getIssueDate().toString() : "未下发"); fillItemInfo(data, mps.getProductId()); // 产品库存情况 List stocks = materialStockMapper.selectList( new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper() .ne(MesWmMaterialStockDO::getQuantity, BigDecimal.ZERO)); if (CollUtil.isNotEmpty(stocks)) { List> stockList = new ArrayList<>(); for (MesWmMaterialStockDO stock : stocks) { Map item = new LinkedHashMap<>(); item.put("物料ID", stock.getItemId()); item.put("物料名称", resolveItemName(stock.getItemId())); item.put("可用量", stock.getQuantity()); stockList.add(item); } data.put("库存物料", stockList); } data.put("提示", "MPS 未下发无工单数据,请结合产品特性和库存自行判断风险"); return data; } private MesProAiRiskPredictRespVO parseRiskResponse(String aiResponse) { MesProAiRiskPredictRespVO respVO = new MesProAiRiskPredictRespVO(); try { String json = extractJson(aiResponse); Map parsed = JSONUtil.toBean(json, Map.class); Object riskLevel = parsed.get("overallRiskLevel"); if (riskLevel instanceof Number num) respVO.setOverallRiskLevel(num.intValue()); respVO.setReasoning((String) parsed.get("reasoning")); @SuppressWarnings("unchecked") List keyPoints = (List) parsed.get("keyPoints"); respVO.setKeyPoints(keyPoints); @SuppressWarnings("unchecked") List> risks = (List>) parsed.get("risks"); if (CollUtil.isNotEmpty(risks)) { respVO.setRisks(risks.stream().map(m -> { MesProAiRiskPredictRespVO.RiskItem ri = new MesProAiRiskPredictRespVO.RiskItem(); ri.setCategory((String) m.get("category")); ri.setDescription((String) m.get("description")); ri.setSeverity((String) m.get("severity")); ri.setSuggestion((String) m.get("suggestion")); return ri; }).collect(Collectors.toList())); } } catch (Exception e) { log.warn("解析 AI 风险预测响应失败: {}", aiResponse, e); respVO.setReasoning(aiResponse); } return respVO; } // ==================== 4. 是否能按时交付 ==================== @Override public MesProAiDeliveryPredictRespVO predictDelivery(MesProAiDeliveryPredictReqVO reqVO) { Map data = null; String logPrefix; try { if (reqVO.getMpsId() != null) { data = gatherDeliveryDataFromMps(reqVO.getMpsId()); logPrefix = "mpsId=" + reqVO.getMpsId(); } else { data = gatherDeliveryData(reqVO.getWorkOrderId()); logPrefix = "workOrderId=" + reqVO.getWorkOrderId(); } String aiResponse = aiChatApi.chat(SYSTEM_PROMPT_DELIVERY, JSONUtil.toJsonPrettyStr(data)); log.info("AI 交付预测: {}, response={}", logPrefix, aiResponse); return parseDeliveryResponse(aiResponse); } catch (Exception e) { log.warn("AI 交付预测失败,workOrderId={}, mpsId={},切换本地规则评估", reqVO.getWorkOrderId(), reqVO.getMpsId(), e); return buildDeliveryFallback(reqVO, data); } } /** * AI 不可用时的本地交付评估:生产进度 + 剩余交期(按 8 小时/天)+ 剩余预测工时 + 物料缺口。 */ private MesProAiDeliveryPredictRespVO buildDeliveryFallback(MesProAiDeliveryPredictReqVO reqVO, Map gathered) { MesProAiDeliveryPredictRespVO respVO = new MesProAiDeliveryPredictRespVO(); List delayFactors = new ArrayList<>(); try { Long effectiveWorkOrderId = resolveWorkOrderId(reqVO.getWorkOrderId(), reqVO.getMpsId()); Map 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 materialDemands = effectiveWorkOrderId != null ? buildMaterialDemandsFromWorkOrder(effectiveWorkOrderId) : (reqVO.getMpsId() != null ? buildMaterialDemandsFromMps(reqVO.getMpsId()) : Collections.emptyList()); Map 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 gatherDeliveryData(Long workOrderId) { Map data = new LinkedHashMap<>(); MesProWorkOrderDO workOrder = workOrderService.getWorkOrder(workOrderId); if (workOrder == null) throw exception(PRO_WORK_ORDER_NOT_EXISTS); data.put("工单编号", workOrder.getCode()); data.put("工单名称", workOrder.getName()); data.put("计划数量", workOrder.getQuantity()); data.put("已生产数量", workOrder.getQuantityProduced()); data.put("工单状态", workOrder.getStatus()); data.put("需求日期", workOrder.getRequestDate() != null ? workOrder.getRequestDate().toString() : "未设置"); data.put("完成日期", workOrder.getFinishDate() != null ? workOrder.getFinishDate().toString() : "未完成"); fillItemInfo(data, workOrder.getProductId()); // 进度计算 BigDecimal progressPercent = BigDecimal.ZERO; if (workOrder.getQuantity() != null && workOrder.getQuantity().compareTo(BigDecimal.ZERO) > 0 && workOrder.getQuantityProduced() != null) { progressPercent = workOrder.getQuantityProduced() .divide(workOrder.getQuantity(), 4, RoundingMode.HALF_UP) .multiply(BigDecimal.valueOf(100)); } data.put("生产进度(%)", progressPercent.setScale(1, RoundingMode.HALF_UP)); // 剩余天数 int remainingDays = 0; if (workOrder.getRequestDate() != null) { remainingDays = (int) ChronoUnit.DAYS.between(LocalDate.now(), workOrder.getRequestDate().toLocalDate()); } data.put("距离需求日期剩余天数", Math.max(0, remainingDays)); // 任务完成情况 List tasks = taskMapper.selectListByWorkOrderId(workOrderId); if (CollUtil.isNotEmpty(tasks)) { List> taskData = new ArrayList<>(); long finishedTasks = 0; long totalDuration = 0; for (MesProTaskDO task : tasks) { Map t = new LinkedHashMap<>(); t.put("任务编号", task.getCode()); t.put("任务名称", task.getName()); t.put("任务状态", task.getStatus()); t.put("计划数量", task.getQuantity()); t.put("已生产数量", task.getProducedQuantity()); t.put("实际时长(小时)", task.getDuration()); if (task.getStartTime() != null) t.put("开始时间", task.getStartTime().toString()); if (task.getEndTime() != null) t.put("结束时间", task.getEndTime().toString()); if (task.getLastFeedbackTime() != null) t.put("最后报工时间", task.getLastFeedbackTime().toString()); taskData.add(t); if (task.getStatus() != null && task.getStatus() >= 3) finishedTasks++; if (task.getDuration() != null) totalDuration += task.getDuration(); } data.put("任务列表", taskData); data.put("已完成任务数", finishedTasks); data.put("总任务数", tasks.size()); data.put("已完成总时长(小时)", totalDuration); } // MPS 和客户信息 MesProMpsDO mps = mpsMapper.selectOne( new LambdaQueryWrapper() .eq(MesProMpsDO::getWorkOrderId, workOrderId)); if (mps != null) { data.put("MPS 编号", mps.getCode()); data.put("销售订单号", mps.getSaleOrderNo()); data.put("MPS 需求日期", mps.getRequestDate() != null ? mps.getRequestDate().toString() : ""); data.put("MPS 下发日期", mps.getIssueDate() != null ? mps.getIssueDate().toString() : ""); fillClientInfo(data, mps.getClientId()); } return data; } private Map gatherDeliveryDataFromMps(Long mpsId) { Map data = new LinkedHashMap<>(); MesProMpsDO mps = mpsService.getMps(mpsId); if (mps == null) throw exception(PRO_WORK_ORDER_NOT_EXISTS); data.put("MPS 编号", mps.getCode()); data.put("来源", "MPS 未下发,基于产品信息评估"); data.put("计划数量", mps.getQuantity()); data.put("已生产数量", mps.getQuantityProduced() != null ? mps.getQuantityProduced() : BigDecimal.ZERO); data.put("MPS 状态", mps.getStatus()); data.put("销售订单号", mps.getSaleOrderNo()); data.put("需求日期", mps.getRequestDate() != null ? mps.getRequestDate().toString() : "未设置"); data.put("下发日期", mps.getIssueDate() != null ? mps.getIssueDate().toString() : "未下发"); fillItemInfo(data, mps.getProductId()); fillClientInfo(data, mps.getClientId()); // 进度计算 BigDecimal progressPercent = BigDecimal.ZERO; if (mps.getQuantity() != null && mps.getQuantity().compareTo(BigDecimal.ZERO) > 0 && mps.getQuantityProduced() != null) { progressPercent = mps.getQuantityProduced() .divide(mps.getQuantity(), 4, RoundingMode.HALF_UP) .multiply(BigDecimal.valueOf(100)); } data.put("生产进度(%)", progressPercent.setScale(1, RoundingMode.HALF_UP)); // 剩余天数 int remainingDays = 0; if (mps.getRequestDate() != null) { remainingDays = (int) ChronoUnit.DAYS.between(LocalDate.now(), mps.getRequestDate().toLocalDate()); } data.put("距离需求日期剩余天数", Math.max(0, remainingDays)); return data; } private MesProAiDeliveryPredictRespVO parseDeliveryResponse(String aiResponse) { MesProAiDeliveryPredictRespVO respVO = new MesProAiDeliveryPredictRespVO(); try { String json = extractJson(aiResponse); Map parsed = JSONUtil.toBean(json, Map.class); Object onTime = parsed.get("onTime"); if (onTime instanceof Boolean b) respVO.setOnTime(b); Object confidence = parsed.get("confidence"); if (confidence instanceof Number num) respVO.setConfidence(num.doubleValue()); respVO.setReasoning((String) parsed.get("reasoning")); @SuppressWarnings("unchecked") List delayFactors = (List) parsed.get("delayFactors"); respVO.setDelayFactors(delayFactors); respVO.setProgressPercent(toBigDecimal(parsed.get("progressPercent"))); respVO.setRemainingDays(toInteger(parsed.get("remainingDays"))); } catch (Exception e) { log.warn("解析 AI 交付预测响应失败: {}", aiResponse, e); respVO.setReasoning(aiResponse); } return respVO; } // ==================== Shared Utilities ==================== @SuppressWarnings("unchecked") private List> asMapList(Object value) { if (value instanceof List list) { return (List>) 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("}"); if (start >= 0 && end > start) { return aiResponse.substring(start, end + 1); } return aiResponse; } private BigDecimal toBigDecimal(Object value) { if (value == null) return BigDecimal.ZERO; if (value instanceof BigDecimal v) return v; if (value instanceof Number n) return BigDecimal.valueOf(n.doubleValue()); try { return new BigDecimal(value.toString()); } catch (Exception e) { return BigDecimal.ZERO; } } private Integer toInteger(Object value) { if (value == null) return 0; if (value instanceof Integer v) return v; if (value instanceof Number n) return n.intValue(); try { return Integer.valueOf(value.toString()); } catch (Exception e) { return 0; } } private String resolveItemName(Long itemId) { if (itemId == null) return ""; try { MdmItemRespDTO item = mdmItemApi.getItem(itemId).getCheckedData(); if (item != null && StrUtil.isNotBlank(item.getName())) return item.getName(); } catch (Exception ignored) {} return "物料" + itemId; } private String resolveProcessName(Long processId) { if (processId == null) return ""; try { MesProProcessDO process = processService.getProcess(processId); if (process != null && StrUtil.isNotBlank(process.getName())) return process.getName(); } catch (Exception ignored) {} return "工序" + processId; } private void fillItemInfo(Map data, Long itemId) { if (itemId == null) return; try { MdmItemRespDTO item = mdmItemApi.getItem(itemId).getCheckedData(); if (item != null) { data.put("物料编码", item.getCode() != null ? item.getCode() : itemId); data.put("物料名称", item.getName() != null ? item.getName() : ""); } } catch (Exception e) { data.put("物料ID", itemId); } } private void fillClientInfo(Map data, Long clientId) { if (clientId == null) return; try { CrmCustomerRespDTO customer = crmCustomerApi.getCustomer(clientId); if (customer != null) { data.put("客户名称", customer.getName() != null ? customer.getName() : ""); } } catch (Exception e) { data.put("客户ID", clientId); } } }