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<Long, BigDecimal> perUnitBom = getPerUnitStandardBom(workOrder);
|
Map<Long, BigDecimal> actualQuantities = getActualConsumption(workOrderId);
|
Set<Long> itemIds = new HashSet<>(perUnitBom.keySet());
|
itemIds.addAll(actualQuantities.keySet());
|
Map<Long, MesProMaterialPriceRespDTO> 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<Long, BigDecimal> perUnitBom = getPerUnitStandardBom(workOrder);
|
if (perUnitBom.isEmpty()) {
|
return;
|
}
|
Map<Long, BigDecimal> 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<Long, BigDecimal> overQuantityMap = new LinkedHashMap<>();
|
for (Map.Entry<Long, BigDecimal> 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<Long> itemIds = new HashSet<>(overQuantityMap.keySet());
|
itemIds.add(workOrder.getProductId());
|
Map<Long, MesMdItemDO> itemMap = itemService.getItemMap(itemIds);
|
Map<Long, MesProMaterialPriceRespDTO> priceMap = getPriceMap(overQuantityMap.keySet());
|
MesMdItemDO product = itemMap.get(workOrder.getProductId());
|
String notifyUserIds = getNotifyUserIds();
|
for (Map.Entry<Long, BigDecimal> 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<String, Object> 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<String> 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<Long, BigDecimal> getPerUnitStandardBom(MesProWorkOrderDO workOrder) {
|
Map<Long, BigDecimal> perUnitBom = new LinkedHashMap<>();
|
List<MesMdProductBomDO> 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<MesProWorkOrderBomDO> 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<Long, BigDecimal> getActualConsumption(Long workOrderId) {
|
Map<Long, BigDecimal> 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<MesProFeedbackDO> feedbackList = feedbackMapper.selectList(
|
new LambdaQueryWrapperX<MesProFeedbackDO>()
|
.eq(MesProFeedbackDO::getWorkOrderId, workOrder.getId())
|
.in(MesProFeedbackDO::getStatus,
|
MesProFeedbackStatusEnum.FINISHED.getStatus(),
|
MesProFeedbackStatusEnum.UNCHECK.getStatus()));
|
if (CollUtil.isEmpty(feedbackList)) {
|
return reported;
|
}
|
Map<Long, BigDecimal> 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<Long, MesProMaterialPriceRespDTO> getPriceMap(Collection<Long> 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<Long, MesProMaterialPriceRespDTO> 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);
|
}
|
|
}
|