package cn.iocoder.yudao.module.mes.service.pro.cost; import cn.hutool.core.collection.CollUtil; import cn.hutool.core.util.StrUtil; import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX; import cn.iocoder.yudao.module.infra.api.config.ConfigApi; import cn.iocoder.yudao.module.mes.dal.dataobject.md.item.MesMdItemDO; import cn.iocoder.yudao.module.mes.dal.dataobject.md.item.MesMdProductBomDO; import cn.iocoder.yudao.module.mes.dal.dataobject.pro.alert.MesProOverConsumeAlertDO; import cn.iocoder.yudao.module.mes.dal.dataobject.pro.feedback.MesProFeedbackDO; 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.wm.itemconsume.MesWmItemConsumeLineDO; import cn.iocoder.yudao.module.mes.dal.mysql.pro.alert.MesProOverConsumeAlertMapper; import cn.iocoder.yudao.module.mes.dal.mysql.pro.feedback.MesProFeedbackMapper; import cn.iocoder.yudao.module.mes.dal.mysql.pro.workorder.MesProWorkOrderMapper; import cn.iocoder.yudao.module.mes.dal.mysql.wm.itemconsume.MesWmItemConsumeLineMapper; import cn.iocoder.yudao.module.mes.enums.pro.MesProFeedbackStatusEnum; import cn.iocoder.yudao.module.mes.service.md.item.MesMdItemService; import cn.iocoder.yudao.module.mes.service.md.item.MesMdProductBomService; import cn.iocoder.yudao.module.mes.service.pro.cost.dto.MesProMaterialPriceRespDTO; import cn.iocoder.yudao.module.mes.service.pro.workorder.MesProWorkOrderBomService; import cn.iocoder.yudao.module.system.api.notify.NotifyMessageSendApi; import cn.iocoder.yudao.module.system.api.notify.dto.NotifySendSingleToUserReqDTO; import jakarta.annotation.Resource; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; import org.springframework.validation.annotation.Validated; import java.math.BigDecimal; import java.math.RoundingMode; import java.time.LocalDateTime; import java.util.ArrayList; import java.util.Collection; import java.util.Collections; import java.util.HashMap; import java.util.HashSet; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.Set; /** * MES 生产工单物料成本 Service 实现类 * * @author 超级管理员 */ @Service @Validated @Slf4j public class MesProWorkOrderCostServiceImpl implements MesProWorkOrderCostService { /** * 全局默认超耗报警比例(%) */ private static final String CONFIG_DEFAULT_RATIO = "mes.pro-over-consume.default-ratio"; /** * 超耗报警站内信接收人用户编号(逗号分隔) */ private static final String CONFIG_NOTIFY_USER_IDS = "mes.pro-over-consume.notify-user-ids"; /** * 超耗报警站内信模板编号 */ private static final String TEMPLATE_CODE = "mes_pro_work_order_over_consume"; private static final BigDecimal DEFAULT_THRESHOLD = new BigDecimal("5"); private static final BigDecimal HUNDRED = new BigDecimal("100"); private static final int SCALE = 6; @Resource private MesProWorkOrderMapper workOrderMapper; @Resource private MesProWorkOrderBomService workOrderBomService; @Resource private MesMdProductBomService productBomService; @Resource private MesMdItemService itemService; @Resource private MesProMaterialPriceService materialPriceService; @Resource private MesProFeedbackMapper feedbackMapper; @Resource private MesWmItemConsumeLineMapper consumeLineMapper; @Resource private MesProOverConsumeAlertMapper alertMapper; @Resource private ConfigApi configApi; @Resource private NotifyMessageSendApi notifyMessageSendApi; @Override public void recalculateWorkOrderCost(Long workOrderId) { if (workOrderId == null) { return; } MesProWorkOrderDO workOrder = workOrderMapper.selectById(workOrderId); if (workOrder == null) { return; } // 1. 标准 BOM 单位用量 + 实际消耗 Map perUnitBom = getPerUnitStandardBom(workOrder); Map actualQuantities = getActualConsumption(workOrderId); Set itemIds = new HashSet<>(perUnitBom.keySet()); itemIds.addAll(actualQuantities.keySet()); Map priceMap = getPriceMap(itemIds); BigDecimal plannedQuantity = defaultZero(workOrder.getQuantity()); BigDecimal reportedQuantity = getReportedQuantity(workOrder); BigDecimal standardCost = BigDecimal.ZERO; BigDecimal actualCost = BigDecimal.ZERO; BigDecimal overCost = BigDecimal.ZERO; for (Long itemId : itemIds) { BigDecimal unitPrice = getUnitPrice(priceMap, itemId); BigDecimal perUnit = perUnitBom.getOrDefault(itemId, BigDecimal.ZERO); BigDecimal actualQuantity = actualQuantities.getOrDefault(itemId, BigDecimal.ZERO); standardCost = standardCost.add(perUnit.multiply(plannedQuantity).multiply(unitPrice)); actualCost = actualCost.add(actualQuantity.multiply(unitPrice)); BigDecimal standardForReported = perUnit.multiply(reportedQuantity); if (standardForReported.compareTo(BigDecimal.ZERO) > 0) { BigDecimal overQuantity = actualQuantity.subtract(standardForReported); if (overQuantity.compareTo(BigDecimal.ZERO) > 0) { overCost = overCost.add(overQuantity.multiply(unitPrice)); } } } // 2. 回写工单 BigDecimal materialUnitCost = reportedQuantity.compareTo(BigDecimal.ZERO) > 0 ? actualCost.divide(reportedQuantity, SCALE, RoundingMode.HALF_UP) : null; workOrderMapper.updateById(new MesProWorkOrderDO().setId(workOrderId) .setStandardMaterialCost(scale(standardCost)) .setActualMaterialCost(scale(actualCost)) .setOverConsumeCost(scale(overCost)) .setMaterialUnitCost(materialUnitCost) .setCostUpdateTime(LocalDateTime.now())); } @Override public void handleFeedbackApproved(MesProFeedbackDO feedback) { if (feedback == null || feedback.getWorkOrderId() == null) { return; } try { recalculateWorkOrderCost(feedback.getWorkOrderId()); } catch (Exception ex) { log.error("[handleFeedbackApproved][工单({}) 重算物料成本失败]", feedback.getWorkOrderId(), ex); } try { checkOverConsumeAlerts(feedback.getWorkOrderId()); } catch (Exception ex) { log.error("[handleFeedbackApproved][工单({}) 超耗报警检查失败]", feedback.getWorkOrderId(), ex); } } // ==================== 超耗报警 ==================== private void checkOverConsumeAlerts(Long workOrderId) { MesProWorkOrderDO workOrder = workOrderMapper.selectById(workOrderId); if (workOrder == null) { return; } Map perUnitBom = getPerUnitStandardBom(workOrder); if (perUnitBom.isEmpty()) { return; } Map actualQuantities = getActualConsumption(workOrderId); if (actualQuantities.isEmpty()) { return; } BigDecimal reportedQuantity = getReportedQuantity(workOrder); BigDecimal threshold = resolveThreshold(workOrder); BigDecimal allowedFactor = BigDecimal.ONE.add(threshold.divide(HUNDRED, SCALE, RoundingMode.HALF_UP)); // 1. 找出超耗物料 Map overQuantityMap = new LinkedHashMap<>(); for (Map.Entry entry : perUnitBom.entrySet()) { BigDecimal standardQuantity = entry.getValue().multiply(reportedQuantity); if (standardQuantity.compareTo(BigDecimal.ZERO) <= 0) { continue; } BigDecimal actualQuantity = actualQuantities.getOrDefault(entry.getKey(), BigDecimal.ZERO); if (actualQuantity.compareTo(standardQuantity.multiply(allowedFactor)) > 0) { overQuantityMap.put(entry.getKey(), actualQuantity.subtract(standardQuantity)); } } if (overQuantityMap.isEmpty()) { return; } // 2. 逐个物料生成报警记录(同一工单+物料只报警一次) Set itemIds = new HashSet<>(overQuantityMap.keySet()); itemIds.add(workOrder.getProductId()); Map itemMap = itemService.getItemMap(itemIds); Map priceMap = getPriceMap(overQuantityMap.keySet()); MesMdItemDO product = itemMap.get(workOrder.getProductId()); String notifyUserIds = getNotifyUserIds(); for (Map.Entry entry : overQuantityMap.entrySet()) { Long itemId = entry.getKey(); if (alertMapper.selectByWorkOrderIdAndItemId(workOrderId, itemId) != null) { continue; } MesMdItemDO item = itemMap.get(itemId); BigDecimal perUnit = perUnitBom.getOrDefault(itemId, BigDecimal.ZERO); BigDecimal standardQuantity = perUnit.multiply(reportedQuantity); BigDecimal actualQuantity = actualQuantities.getOrDefault(itemId, BigDecimal.ZERO); BigDecimal overQuantity = entry.getValue(); BigDecimal unitPrice = getUnitPrice(priceMap, itemId); MesProOverConsumeAlertDO alert = MesProOverConsumeAlertDO.builder() .workOrderId(workOrderId).workOrderCode(workOrder.getCode()) .productId(workOrder.getProductId()) .productName(product != null ? product.getName() : null) .itemId(itemId).itemCode(item != null ? item.getCode() : null) .itemName(item != null ? item.getName() : null) .standardQuantity(scale(standardQuantity)) .actualQuantity(scale(actualQuantity)) .overQuantity(scale(overQuantity)) .overRatio(overQuantity.multiply(HUNDRED).divide(standardQuantity, 4, RoundingMode.HALF_UP)) .thresholdRatio(threshold.setScale(4, RoundingMode.HALF_UP)) .unitPrice(scale(unitPrice)) .overAmount(scale(overQuantity.multiply(unitPrice))) .notifyUserIds(notifyUserIds) .status("FAILED") .build(); alertMapper.insert(alert); try { sendOverConsumeMessage(alert, notifyUserIds); } catch (Exception ex) { log.warn("[checkOverConsumeAlerts][工单({}) 物料({}) 超耗站内信发送失败]", workOrder.getCode(), item != null ? item.getCode() : itemId, ex); alert.setStatus("FAILED"); alert.setErrorMessage(StrUtil.maxLength( "发送失败:" + StrUtil.blankToDefault(ex.getMessage(), ex.getClass().getSimpleName()), 500)); } alertMapper.updateById(alert); } } private void sendOverConsumeMessage(MesProOverConsumeAlertDO alert, String notifyUserIds) { if (StrUtil.isBlank(notifyUserIds)) { throw new IllegalStateException( "未配置超耗报警接收人,请在参数配置中设置 mes.pro-over-consume.notify-user-ids"); } Map params = new HashMap<>(); params.put("workOrderCode", StrUtil.blankToDefault(alert.getWorkOrderCode(), "")); params.put("productName", StrUtil.blankToDefault(alert.getProductName(), "")); params.put("itemName", StrUtil.blankToDefault(alert.getItemName(), "")); params.put("standardQuantity", alert.getStandardQuantity().toPlainString()); params.put("actualQuantity", alert.getActualQuantity().toPlainString()); params.put("overQuantity", alert.getOverQuantity().toPlainString()); params.put("overRatio", alert.getOverRatio().toPlainString()); params.put("threshold", alert.getThresholdRatio().stripTrailingZeros().toPlainString()); params.put("overAmount", alert.getOverAmount().toPlainString()); List failures = new ArrayList<>(); Long firstMessageId = null; int successCount = 0; for (String userIdText : notifyUserIds.split(",")) { String trimmed = userIdText.trim(); if (trimmed.isEmpty()) { continue; } try { Long userId = Long.parseLong(trimmed); NotifySendSingleToUserReqDTO message = new NotifySendSingleToUserReqDTO(); message.setUserId(userId); message.setTemplateCode(TEMPLATE_CODE); message.setTemplateParams(params); Long messageId = notifyMessageSendApi.sendSingleMessageToAdmin(message); if (messageId == null) { throw new IllegalStateException("站内信模板已停用或不存在"); } if (firstMessageId == null) { firstMessageId = messageId; } successCount++; } catch (Exception ex) { failures.add("用户[" + trimmed + "]:" + StrUtil.blankToDefault( ex.getMessage(), ex.getClass().getSimpleName())); } } alert.setMessageId(firstMessageId); if (failures.isEmpty()) { alert.setStatus("SENT"); alert.setErrorMessage(null); return; } alert.setStatus(successCount > 0 ? "SENT" : "FAILED"); alert.setErrorMessage(StrUtil.maxLength("部分接收人发送失败:" + String.join(";", failures), 500)); } // ==================== 成本基础数据 ==================== /** * 产品标准 BOM 单位用量;无产品 BOM 时回退工单 BOM */ private Map getPerUnitStandardBom(MesProWorkOrderDO workOrder) { Map perUnitBom = new LinkedHashMap<>(); List productBomList = productBomService .getProductBomListByItemId(workOrder.getProductId()); if (CollUtil.isNotEmpty(productBomList)) { for (MesMdProductBomDO bom : productBomList) { if (bom.getBomItemId() != null && bom.getQuantity() != null) { perUnitBom.merge(bom.getBomItemId(), bom.getQuantity(), BigDecimal::add); } } return perUnitBom; } List workOrderBomList = workOrderBomService .getWorkOrderBomListByWorkOrderId(workOrder.getId()); if (CollUtil.isEmpty(workOrderBomList)) { return perUnitBom; } BigDecimal workOrderQuantity = defaultZero(workOrder.getQuantity()); BigDecimal divisor = workOrderQuantity.compareTo(BigDecimal.ZERO) > 0 ? workOrderQuantity : BigDecimal.ONE; for (MesProWorkOrderBomDO bom : workOrderBomList) { if (bom.getItemId() == null || bom.getQuantity() == null) { continue; } // 无工序行在手工建单时已按工单数量放大,需折算回单位用量;有工序行为单位用量 BigDecimal perUnit = bom.getProcessId() == null ? bom.getQuantity().divide(divisor, SCALE, RoundingMode.HALF_UP) : bom.getQuantity(); perUnitBom.merge(bom.getItemId(), perUnit, BigDecimal::add); } return perUnitBom; } private Map getActualConsumption(Long workOrderId) { Map actualQuantities = new LinkedHashMap<>(); for (MesWmItemConsumeLineDO line : consumeLineMapper.selectListByWorkOrderId(workOrderId)) { if (line.getItemId() != null && line.getQuantity() != null) { actualQuantities.merge(line.getItemId(), line.getQuantity(), BigDecimal::add); } } return actualQuantities; } /** * 已报工数量:工单已生产数量 与 各工序已审批报工累计数量 取大 * * 工单 quantityProduced 只在末道工序报工后更新,取各工序最大值可保证中间工序报工后即可计算超耗 */ private BigDecimal getReportedQuantity(MesProWorkOrderDO workOrder) { BigDecimal reported = defaultZero(workOrder.getQuantityProduced()); List feedbackList = feedbackMapper.selectList( new LambdaQueryWrapperX() .eq(MesProFeedbackDO::getWorkOrderId, workOrder.getId()) .in(MesProFeedbackDO::getStatus, MesProFeedbackStatusEnum.FINISHED.getStatus(), MesProFeedbackStatusEnum.UNCHECK.getStatus())); if (CollUtil.isEmpty(feedbackList)) { return reported; } Map quantityByProcess = new HashMap<>(); for (MesProFeedbackDO feedback : feedbackList) { if (feedback.getFeedbackQuantity() == null) { continue; } Long key = feedback.getProcessId() != null ? feedback.getProcessId() : 0L; quantityByProcess.merge(key, feedback.getFeedbackQuantity(), BigDecimal::add); } BigDecimal maxProcessQuantity = quantityByProcess.values().stream() .max(BigDecimal::compareTo).orElse(BigDecimal.ZERO); return reported.max(maxProcessQuantity); } private BigDecimal resolveThreshold(MesProWorkOrderDO workOrder) { if (workOrder.getOverConsumeRatio() != null) { return workOrder.getOverConsumeRatio(); } MesMdItemDO product = workOrder.getProductId() != null ? itemService.getItem(workOrder.getProductId()) : null; if (product != null && product.getOverConsumeRatio() != null) { return product.getOverConsumeRatio(); } String configValue = null; try { configValue = configApi.getConfigValueByKey(CONFIG_DEFAULT_RATIO); } catch (Exception ex) { log.warn("[resolveThreshold][读取超耗报警默认比例失败,使用默认值 {}]", DEFAULT_THRESHOLD, ex); } if (StrUtil.isNotBlank(configValue)) { try { return new BigDecimal(configValue.trim()); } catch (NumberFormatException ex) { log.warn("[resolveThreshold][超耗报警默认比例配置值非法:{},使用默认值 {}]", configValue, DEFAULT_THRESHOLD); } } return DEFAULT_THRESHOLD; } private String getNotifyUserIds() { try { return StrUtil.trimToEmpty(configApi.getConfigValueByKey(CONFIG_NOTIFY_USER_IDS)); } catch (Exception ex) { log.warn("[getNotifyUserIds][读取超耗报警接收人失败]", ex); return ""; } } private Map getPriceMap(Collection itemIds) { if (CollUtil.isEmpty(itemIds)) { return Collections.emptyMap(); } try { return materialPriceService.getMaterialPriceMap(itemIds); } catch (Exception ex) { log.error("[getPriceMap][获取物料价格失败,按无价格处理] itemIds={}", itemIds, ex); return Collections.emptyMap(); } } private static BigDecimal getUnitPrice(Map priceMap, Long itemId) { MesProMaterialPriceRespDTO price = priceMap.get(itemId); return price != null && price.getUnitPrice() != null ? price.getUnitPrice() : BigDecimal.ZERO; } private static BigDecimal defaultZero(BigDecimal value) { return value != null ? value : BigDecimal.ZERO; } private static BigDecimal scale(BigDecimal value) { return value.setScale(SCALE, RoundingMode.HALF_UP); } }