package cn.iocoder.yudao.module.mes.service.wm.stockwarning; import cn.hutool.core.collection.CollUtil; import cn.iocoder.yudao.framework.common.exception.ServiceException; import cn.iocoder.yudao.framework.common.pojo.PageResult; import cn.iocoder.yudao.framework.common.util.object.BeanUtils; import cn.iocoder.yudao.module.mes.controller.admin.wm.stockwarning.vo.MesWmStockWarningAlertRespVO; import cn.iocoder.yudao.module.mes.controller.admin.wm.stockwarning.vo.MesWmStockWarningRulePageReqVO; import cn.iocoder.yudao.module.mes.controller.admin.wm.stockwarning.vo.MesWmStockWarningRuleRespVO; import cn.iocoder.yudao.module.mes.controller.admin.wm.stockwarning.vo.MesWmStockWarningRuleSaveReqVO; import cn.iocoder.yudao.module.mes.dal.dataobject.md.unitmeasure.MesMdUnitMeasureDO; import cn.iocoder.yudao.module.mes.dal.dataobject.mdm.MesMdmItemDO; import cn.iocoder.yudao.module.mes.dal.dataobject.wm.stockwarning.MesWmStockWarningRuleDO; import cn.iocoder.yudao.module.mes.dal.dataobject.wm.warehouse.MesWmWarehouseDO; import cn.iocoder.yudao.module.mes.dal.mysql.wm.stockwarning.MesWmStockWarningRuleMapper; import cn.iocoder.yudao.module.mes.service.md.unitmeasure.MesMdUnitMeasureService; import cn.iocoder.yudao.module.mes.service.mdm.MesMdmItemService; import cn.iocoder.yudao.module.mes.service.wm.warehouse.MesWmWarehouseService; import cn.iocoder.yudao.module.system.api.notify.NotifyMessageSendApi; import cn.iocoder.yudao.module.system.api.notify.dto.NotifySendSingleToUserReqDTO; import cn.iocoder.yudao.module.system.api.user.AdminUserApi; import cn.iocoder.yudao.module.system.api.user.dto.AdminUserRespDTO; 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.util.*; import java.util.stream.Collectors; import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception; import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertSet; import static cn.iocoder.yudao.module.mes.enums.ErrorCodeConstants.*; /** * MES 库存预警 Service 实现类 */ @Service @Validated @Slf4j public class MesWmStockWarningServiceImpl implements MesWmStockWarningService { /** 预警类型:低于下限 */ private static final Integer WARNING_BELOW_MIN = 1; /** 预警类型:高于上限 */ private static final Integer WARNING_ABOVE_MAX = 2; /** 站内信模板编码 */ private static final String NOTIFY_TEMPLATE_CODE = "stock_quantity_warning"; @Resource private MesWmStockWarningRuleMapper ruleMapper; @Resource private MesMdmItemService mdmItemService; @Resource private MesMdUnitMeasureService unitMeasureService; @Resource private MesWmWarehouseService warehouseService; @Resource private NotifyMessageSendApi notifyMessageSendApi; @Resource private AdminUserApi adminUserApi; @Override public Long createRule(MesWmStockWarningRuleSaveReqVO createReqVO) { validateItem(createReqVO.getItemId()); validateWarehouse(createReqVO.getWarehouseId()); MesWmStockWarningRuleDO rule = BeanUtils.toBean(createReqVO, MesWmStockWarningRuleDO.class); ruleMapper.insert(rule); return rule.getId(); } @Override public void updateRule(MesWmStockWarningRuleSaveReqVO updateReqVO) { validateRuleExists(updateReqVO.getId()); validateItem(updateReqVO.getItemId()); validateWarehouse(updateReqVO.getWarehouseId()); MesWmStockWarningRuleDO updateObj = BeanUtils.toBean(updateReqVO, MesWmStockWarningRuleDO.class); ruleMapper.updateById(updateObj); } @Override public void deleteRule(Long id) { validateRuleExists(id); ruleMapper.deleteById(id); } @Override public MesWmStockWarningRuleDO getRule(Long id) { return ruleMapper.selectById(id); } @Override public PageResult getRulePage(MesWmStockWarningRulePageReqVO pageReqVO) { PageResult pageResult = ruleMapper.selectPage(pageReqVO); if (CollUtil.isEmpty(pageResult.getList())) { return PageResult.empty(pageResult.getTotal()); } List list = BeanUtils.toBean(pageResult.getList(), MesWmStockWarningRuleRespVO.class); // 拼接物料 / 仓库 / 接收人名称 Map itemMap = mdmItemService.getItemMap( convertSet(list, MesWmStockWarningRuleRespVO::getItemId)); Map warehouseMap = warehouseService.getWarehouseMap( convertSet(list, MesWmStockWarningRuleRespVO::getWarehouseId)); Map userMap = adminUserApi.getUserMap( convertSet(list, MesWmStockWarningRuleRespVO::getNotifyUserId)); list.forEach(vo -> { MesMdmItemDO item = itemMap.get(vo.getItemId()); if (item != null) { vo.setItemCode(item.getCode()); vo.setItemName(item.getName()); } MesWmWarehouseDO warehouse = warehouseMap.get(vo.getWarehouseId()); if (warehouse != null) { vo.setWarehouseName(warehouse.getName()); } AdminUserRespDTO user = userMap.get(vo.getNotifyUserId()); if (user != null) { vo.setNotifyUserName(user.getNickname()); } }); return new PageResult<>(list, pageResult.getTotal()); } @Override public List getAlertList(Long itemId, Long warehouseId) { // 1. 查询启用中的规则 List rules = ruleMapper.selectList( new cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX() .eqIfPresent(MesWmStockWarningRuleDO::getItemId, itemId) .eqIfPresent(MesWmStockWarningRuleDO::getWarehouseId, warehouseId) .eq(MesWmStockWarningRuleDO::getEnabled, true)); if (CollUtil.isEmpty(rules)) { return Collections.emptyList(); } // 2. 按物料汇总当前库存 List sumList = ruleMapper.selectStockQuantitySumList(convertSet(rules, MesWmStockWarningRuleDO::getItemId)); Map itemTotalMap = new HashMap<>(); Map> itemWarehouseMap = new HashMap<>(); for (MesWmStockWarningRuleMapper.StockQuantitySum sum : sumList) { BigDecimal quantity = sum.getQuantity() != null ? sum.getQuantity() : BigDecimal.ZERO; itemTotalMap.merge(sum.getItemId(), quantity, BigDecimal::add); itemWarehouseMap.computeIfAbsent(sum.getItemId(), key -> new HashMap<>()) .merge(sum.getWarehouseId(), quantity, BigDecimal::add); } // 3. 逐条规则判断是否预警 Map itemMap = mdmItemService.getItemMap( convertSet(rules, MesWmStockWarningRuleDO::getItemId)); // 物料单位来自计量单位表,缺失时前端会显示「--」,这里补齐便于仓管直接判断缺口大小 Map unitMap = unitMeasureService.getUnitMeasureMap( convertSet(itemMap.values(), MesMdmItemDO::getUnitMeasureId)); Map warehouseMap = warehouseService.getWarehouseMap( convertSet(rules, MesWmStockWarningRuleDO::getWarehouseId)); List alertList = new ArrayList<>(); for (MesWmStockWarningRuleDO rule : rules) { BigDecimal current = rule.getWarehouseId() == null ? itemTotalMap.getOrDefault(rule.getItemId(), BigDecimal.ZERO) : itemWarehouseMap.getOrDefault(rule.getItemId(), Collections.emptyMap()) .getOrDefault(rule.getWarehouseId(), BigDecimal.ZERO); Integer warningType = judgeWarningType(rule, current); if (warningType == null) { continue; } MesWmStockWarningAlertRespVO alert = new MesWmStockWarningAlertRespVO(); alert.setRuleId(rule.getId()); alert.setItemId(rule.getItemId()); alert.setWarehouseId(rule.getWarehouseId()); alert.setCurrentQuantity(current); alert.setMinQuantity(rule.getMinQuantity()); alert.setMaxQuantity(rule.getMaxQuantity()); alert.setWarningType(warningType); MesMdmItemDO item = itemMap.get(rule.getItemId()); if (item != null) { alert.setItemCode(item.getCode()); alert.setItemName(item.getName()); MesMdUnitMeasureDO unit = unitMap.get(item.getUnitMeasureId()); if (unit != null) { alert.setUnitName(unit.getName()); } } MesWmWarehouseDO warehouse = warehouseMap.get(rule.getWarehouseId()); if (warehouse != null) { alert.setWarehouseName(warehouse.getName()); } alertList.add(alert); } // 4. 先低于下限、再高于上限,便于优先处理缺货 alertList.sort(Comparator.comparing(MesWmStockWarningAlertRespVO::getWarningType) .thenComparing(MesWmStockWarningAlertRespVO::getItemName, Comparator.nullsLast(Comparator.naturalOrder()))); return alertList; } @Override public void checkAfterStockChange(Long itemId, Long warehouseId, BigDecimal deltaQuantity) { if (itemId == null || warehouseId == null) { return; } List rules = ruleMapper.selectListByItemIds(Collections.singletonList(itemId)); // 只保留「全部仓库」或该仓库的规则 rules = rules.stream() .filter(rule -> rule.getWarehouseId() == null || rule.getWarehouseId().equals(warehouseId)) .collect(Collectors.toList()); if (CollUtil.isEmpty(rules)) { return; } // 重新汇总变动后的库存 Map itemTotalMap = new HashMap<>(); Map warehouseTotalMap = new HashMap<>(); for (MesWmStockWarningRuleMapper.StockQuantitySum sum : ruleMapper.selectStockQuantitySumList(Collections.singletonList(itemId))) { BigDecimal quantity = sum.getQuantity() != null ? sum.getQuantity() : BigDecimal.ZERO; itemTotalMap.merge(itemId, quantity, BigDecimal::add); if (warehouseId.equals(sum.getWarehouseId())) { warehouseTotalMap.merge(warehouseId, quantity, BigDecimal::add); } } BigDecimal afterTotal = itemTotalMap.getOrDefault(itemId, BigDecimal.ZERO); BigDecimal afterWarehouse = warehouseTotalMap.getOrDefault(warehouseId, BigDecimal.ZERO); BigDecimal delta = deltaQuantity != null ? deltaQuantity : BigDecimal.ZERO; for (MesWmStockWarningRuleDO rule : rules) { BigDecimal current = rule.getWarehouseId() == null ? afterTotal : afterWarehouse; Integer warningType = judgeWarningType(rule, current); if (warningType == null) { continue; } // 变动前数量 = 变动后数量 - 本次变动量;仅在「本次变动导致越界」时通知,避免每次出入库都重复推送 BigDecimal before = current.subtract(delta); if (!isCrossed(rule, before, current)) { continue; } notifyWarning(rule, current, warningType); } } // ==================== 私有方法 ==================== private Integer judgeWarningType(MesWmStockWarningRuleDO rule, BigDecimal current) { if (current == null) { return null; } if (rule.getMinQuantity() != null && current.compareTo(rule.getMinQuantity()) < 0) { return WARNING_BELOW_MIN; } if (rule.getMaxQuantity() != null && current.compareTo(rule.getMaxQuantity()) > 0) { return WARNING_ABOVE_MAX; } return null; } /** * 判断变动前是否在阈值内、变动后越界,即「本次变动导致越界」 */ private boolean isCrossed(MesWmStockWarningRuleDO rule, BigDecimal before, BigDecimal after) { if (rule.getMinQuantity() != null && after.compareTo(rule.getMinQuantity()) < 0) { return before.compareTo(rule.getMinQuantity()) >= 0; } if (rule.getMaxQuantity() != null && after.compareTo(rule.getMaxQuantity()) > 0) { return before.compareTo(rule.getMaxQuantity()) <= 0; } return false; } private void notifyWarning(MesWmStockWarningRuleDO rule, BigDecimal current, Integer warningType) { try { // 1. 确定接收人:规则指定 > 仓库负责人 Long recipientId = rule.getNotifyUserId(); if (recipientId == null && rule.getWarehouseId() != null) { MesWmWarehouseDO warehouse = warehouseService.getWarehouse(rule.getWarehouseId()); recipientId = warehouse != null ? warehouse.getChargeUserId() : null; } if (recipientId == null) { log.warn("库存预警[规则{}]触发,但未配置通知接收人且仓库无负责人,已跳过站内通知", rule.getId()); return; } // 2. 组装通知内容 MesMdmItemDO item = mdmItemService.getItemMap(Collections.singletonList(rule.getItemId())) .get(rule.getItemId()); String itemName = item != null ? item.getName() : "ID=" + rule.getItemId(); String warehouseName = "全部仓库合计"; if (rule.getWarehouseId() != null) { MesWmWarehouseDO warehouse = warehouseService.getWarehouse(rule.getWarehouseId()); warehouseName = warehouse != null ? warehouse.getName() : "ID=" + rule.getWarehouseId(); } Map params = new HashMap<>(); params.put("itemName", itemName); params.put("warehouseName", warehouseName); params.put("actual", current.stripTrailingZeros().toPlainString()); params.put("min", rule.getMinQuantity() != null ? rule.getMinQuantity().stripTrailingZeros().toPlainString() : "-"); params.put("max", rule.getMaxQuantity() != null ? rule.getMaxQuantity().stripTrailingZeros().toPlainString() : "-"); params.put("warningType", WARNING_BELOW_MIN.equals(warningType) ? "低于库存下限" : "高于库存上限"); NotifySendSingleToUserReqDTO message = new NotifySendSingleToUserReqDTO(); message.setUserId(recipientId); message.setTemplateCode(NOTIFY_TEMPLATE_CODE); message.setTemplateParams(params); notifyMessageSendApi.sendSingleMessageToAdmin(message); } catch (Exception ex) { // 通知失败不影响业务事务 log.warn("库存预警通知发送失败 rule={} item={}", rule.getId(), rule.getItemId(), ex); } } private void validateRuleExists(Long id) { MesWmStockWarningRuleDO rule = ruleMapper.selectById(id); if (rule == null) { throw exception(WM_STOCK_WARNING_RULE_NOT_EXISTS); } } private void validateItem(Long itemId) { MesMdmItemDO item = mdmItemService.getItemMap(Collections.singletonList(itemId)).get(itemId); if (item == null) { throw new ServiceException(WM_STOCK_WARNING_ITEM_NOT_EXISTS.getCode(), "所选物料[ID=" + itemId + "]不存在或已被删除,请重新选择物料。"); } } private void validateWarehouse(Long warehouseId) { if (warehouseId == null) { return; } MesWmWarehouseDO warehouse = warehouseService.getWarehouse(warehouseId); if (warehouse == null) { throw new ServiceException(WM_STOCK_WARNING_WAREHOUSE_NOT_EXISTS.getCode(), "所选仓库[ID=" + warehouseId + "]不存在或已被删除,请重新选择仓库。"); } } }