package cn.iocoder.yudao.module.mes.service.em.alert; import cn.hutool.core.collection.CollUtil; import cn.hutool.core.util.StrUtil; import cn.iocoder.yudao.framework.common.pojo.PageResult; import cn.iocoder.yudao.framework.common.util.object.BeanUtils; import cn.iocoder.yudao.framework.security.core.util.SecurityFrameworkUtils; import cn.iocoder.yudao.module.mes.controller.admin.em.alert.vo.MesEmAlertHandleReqVO; import cn.iocoder.yudao.module.mes.controller.admin.em.alert.vo.MesEmAlertPageReqVO; import cn.iocoder.yudao.module.mes.controller.admin.em.alert.vo.MesEmAlertRespVO; import cn.iocoder.yudao.module.mes.dal.dataobject.em.alert.MesEmAlertDO; import cn.iocoder.yudao.module.mes.dal.dataobject.em.meter.MesEmMeterDO; import cn.iocoder.yudao.module.mes.dal.dataobject.em.quota.MesEmQuotaDO; import cn.iocoder.yudao.module.mes.dal.dataobject.em.record.MesEmRecordDO; import cn.iocoder.yudao.module.mes.dal.mysql.em.alert.MesEmAlertMapper; import cn.iocoder.yudao.module.mes.dal.mysql.em.meter.MesEmMeterMapper; import cn.iocoder.yudao.module.mes.dal.mysql.em.quota.MesEmQuotaMapper; import cn.iocoder.yudao.module.mes.dal.mysql.em.record.MesEmRecordMapper; import cn.iocoder.yudao.module.mes.enums.em.*; import jakarta.annotation.Resource; import org.springframework.stereotype.Service; import org.springframework.validation.annotation.Validated; import java.math.BigDecimal; import java.math.RoundingMode; import java.time.LocalDate; import java.time.LocalDateTime; import java.time.format.DateTimeFormatter; import java.util.Collections; import java.util.Comparator; import java.util.List; import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception; import static cn.iocoder.yudao.module.mes.enums.ErrorCodeConstants.EM_ALERT_ALREADY_HANDLED; import static cn.iocoder.yudao.module.mes.enums.ErrorCodeConstants.EM_ALERT_NOT_EXISTS; /** * MES 能耗预警 Service 实现类 * * @author 超级管理员 */ @Service @Validated public class MesEmAlertServiceImpl implements MesEmAlertService { private static final BigDecimal HUNDRED = new BigDecimal("100"); private static final BigDecimal DEFAULT_MOM_RATIO = new BigDecimal("30"); private static final BigDecimal DEFAULT_YOY_RATIO = new BigDecimal("30"); private static final DateTimeFormatter DATE_NO_FORMATTER = DateTimeFormatter.ofPattern("yyyyMMdd"); @Resource private MesEmAlertMapper alertMapper; @Resource private MesEmRecordMapper recordMapper; @Resource private MesEmMeterMapper meterMapper; @Resource private MesEmQuotaMapper quotaMapper; @Override public void checkRecord(MesEmRecordDO record) { MesEmMeterDO meter = meterMapper.selectById(record.getMeterId()); checkQuota(record, MesEmQuotaPeriodEnum.DAY); checkQuota(record, MesEmQuotaPeriodEnum.MONTH); checkMomentum(record); checkLeak(record, meter); } /** * 定额预警:日定额比较当日用量,月定额比较月初至当日累计用量 */ private void checkQuota(MesEmRecordDO record, MesEmQuotaPeriodEnum period) { List quotas = quotaMapper.selectListByObject(record.getEnergyType(), period.getPeriod(), record.getWorkshopId(), record.getLineId(), record.getMachineryId()); MesEmQuotaDO quota = mostSpecificQuota(quotas); if (quota == null) { return; } LocalDate begin = MesEmQuotaPeriodEnum.MONTH.equals(period) ? record.getRecordDate().withDayOfMonth(1) : record.getRecordDate(); BigDecimal actual = recordMapper.sumUsage(record.getEnergyType(), quota.getObjectType(), quota.getObjectId(), begin, record.getRecordDate()); BigDecimal quotaValue = quota.getQuotaValue() == null ? BigDecimal.ZERO : quota.getQuotaValue(); BigDecimal warnRatio = defaultIfNull(quota.getWarnRatio(), new BigDecimal("10")); BigDecimal alarmRatio = defaultIfNull(quota.getAlarmRatio(), new BigDecimal("20")); BigDecimal warnThreshold = quotaValue.multiply(BigDecimal.ONE.add( warnRatio.divide(HUNDRED, 6, RoundingMode.HALF_UP))); BigDecimal alarmThreshold = quotaValue.multiply(BigDecimal.ONE.add( alarmRatio.divide(HUNDRED, 6, RoundingMode.HALF_UP))); Long objectId = quota.getObjectId() == null ? 0L : quota.getObjectId(); if (actual.compareTo(warnThreshold) <= 0) { // 用量回落到阈值内时,撤销尚未处理的预警 deleteIfPending(0L, MesEmAlertTypeEnum.QUOTA.getType(), record.getRecordDate(), objectId); return; } BigDecimal exceedRate = rate(actual, quotaValue); String unit = StrUtil.blankToDefault(record.getUnit(), StrUtil.blankToDefault(quota.getUnit(), "")); String content = StrUtil.format("{}{} {} 用量 {} {},定额 {} {},超出 {}%", period.getName(), quota.getObjectName(), MesEmEnergyTypeEnum.getName(record.getEnergyType()), stripZeros(actual), unit, stripZeros(quotaValue), unit, exceedRate == null ? "-" : stripZeros(exceedRate)); MesEmAlertDO alert = buildAlert(record, 0L, "", quota.getObjectName(), MesEmAlertTypeEnum.QUOTA, actual.compareTo(alarmThreshold) > 0 ? MesEmAlertLevelEnum.SERIOUS : MesEmAlertLevelEnum.WARN, quota.getObjectType(), objectId, actual, quotaValue, unit, content); upsertAlert(alert); } /** * 环比 / 同比异常预警:与上日、去年同日比较 */ private void checkMomentum(MesEmRecordDO record) { List quotas = quotaMapper.selectListByObject(record.getEnergyType(), null, record.getWorkshopId(), record.getLineId(), record.getMachineryId()); MesEmQuotaDO quota = mostSpecificQuota(quotas); BigDecimal momRatio = quota == null ? DEFAULT_MOM_RATIO : defaultIfNull(quota.getMomWarnRatio(), DEFAULT_MOM_RATIO); BigDecimal yoyRatio = quota == null ? DEFAULT_YOY_RATIO : defaultIfNull(quota.getYoyWarnRatio(), DEFAULT_YOY_RATIO); BigDecimal current = defaultIfNull(record.getUsageAmount(), BigDecimal.ZERO); String unit = StrUtil.blankToDefault(record.getUnit(), ""); // 环比:昨日 LocalDate prevDate = record.getRecordDate().minusDays(1); BigDecimal previous = recordMapper.sumMeterUsage(record.getMeterId(), prevDate, prevDate); checkMomentumItem(record, current, previous, momRatio, unit, MesEmAlertTypeEnum.MOM, "环比", prevDate); // 同比:去年同日 LocalDate lastYearDate = record.getRecordDate().minusYears(1); BigDecimal lastYear = recordMapper.sumMeterUsage(record.getMeterId(), lastYearDate, lastYearDate); checkMomentumItem(record, current, lastYear, yoyRatio, unit, MesEmAlertTypeEnum.YOY, "同比", lastYearDate); } private void checkMomentumItem(MesEmRecordDO record, BigDecimal current, BigDecimal base, BigDecimal thresholdRatio, String unit, MesEmAlertTypeEnum alertType, String label, LocalDate baseDate) { if (base == null || base.compareTo(BigDecimal.ZERO) <= 0) { deleteIfPending(record.getMeterId(), alertType.getType(), record.getRecordDate(), 0L); return; } BigDecimal changeRate = rate(current, base); if (changeRate == null || changeRate.compareTo(thresholdRatio) <= 0) { deleteIfPending(record.getMeterId(), alertType.getType(), record.getRecordDate(), 0L); return; } MesEmAlertLevelEnum level = changeRate.compareTo(thresholdRatio.multiply(new BigDecimal("2"))) > 0 ? MesEmAlertLevelEnum.SERIOUS : MesEmAlertLevelEnum.WARN; String content = StrUtil.format("{} {} {} 用量 {} {},较 {} 的 {} {} 上升 {}%,超过{}异常阈值 {}%", record.getMeterName(), MesEmEnergyTypeEnum.getName(record.getEnergyType()), label, stripZeros(current), unit, baseDate, label, stripZeros(base), stripZeros(changeRate), label, stripZeros(thresholdRatio)); MesEmAlertDO alert = buildAlert(record, record.getMeterId(), record.getMeterCode(), record.getMeterName(), alertType, level, resolveObjectType(record), resolveObjectId(record), current, base, unit, content); upsertAlert(alert); } /** * 疑似跑冒滴漏预警:当日用量超过计量点配置的异常上限 */ private void checkLeak(MesEmRecordDO record, MesEmMeterDO meter) { BigDecimal threshold = meter == null ? null : meter.getLeakThreshold(); BigDecimal current = defaultIfNull(record.getUsageAmount(), BigDecimal.ZERO); if (threshold == null || current.compareTo(threshold) <= 0) { deleteIfPending(record.getMeterId(), MesEmAlertTypeEnum.LEAK.getType(), record.getRecordDate(), 0L); return; } MesEmAlertLevelEnum level = current.compareTo(threshold.multiply(new BigDecimal("1.5"))) > 0 ? MesEmAlertLevelEnum.SERIOUS : MesEmAlertLevelEnum.WARN; String unit = StrUtil.blankToDefault(record.getUnit(), ""); String content = StrUtil.format("{} 当日用量 {} {},超过日用量异常上限 {} {},疑似跑冒滴漏", record.getMeterName(), stripZeros(current), unit, stripZeros(threshold), unit); MesEmAlertDO alert = buildAlert(record, record.getMeterId(), record.getMeterCode(), record.getMeterName(), MesEmAlertTypeEnum.LEAK, level, resolveObjectType(record), resolveObjectId(record), current, threshold, unit, content); upsertAlert(alert); } private MesEmAlertDO buildAlert(MesEmRecordDO record, Long meterId, String meterCode, String meterName, MesEmAlertTypeEnum alertType, MesEmAlertLevelEnum alertLevel, Integer objectType, Long objectId, BigDecimal actualValue, BigDecimal thresholdValue, String unit, String content) { MesEmAlertDO alert = new MesEmAlertDO(); alert.setAlertNo(buildAlertNo(record.getRecordDate(), meterId, alertType.getType(), objectId)); alert.setMeterId(meterId); alert.setMeterCode(meterCode); alert.setMeterName(meterName); alert.setEnergyType(record.getEnergyType()); alert.setAlertType(alertType.getType()); alert.setAlertLevel(alertLevel.getLevel()); alert.setAlertDate(record.getRecordDate()); alert.setObjectType(objectType); alert.setObjectId(objectId); alert.setObjectName(meterName); alert.setActualValue(actualValue); alert.setThresholdValue(thresholdValue); alert.setUnit(unit); alert.setContent(content); alert.setStatus(MesEmAlertStatusEnum.PENDING.getStatus()); return alert; } private void upsertAlert(MesEmAlertDO alert) { MesEmAlertDO existing = alertMapper.selectByUnique(alert.getMeterId(), alert.getAlertType(), alert.getAlertDate(), alert.getObjectId()); if (existing == null) { alertMapper.insert(alert); return; } alert.setId(existing.getId()); alert.setAlertNo(existing.getAlertNo()); alert.setStatus(existing.getStatus()); alertMapper.updateById(alert); } private void deleteIfPending(Long meterId, Integer alertType, LocalDate alertDate, Long objectId) { MesEmAlertDO existing = alertMapper.selectByUnique(meterId, alertType, alertDate, objectId); if (existing != null && MesEmAlertStatusEnum.PENDING.getStatus().equals(existing.getStatus())) { alertMapper.deleteById(existing.getId()); } } private String buildAlertNo(LocalDate date, Long meterId, Integer alertType, Long objectId) { return "AL" + date.format(DATE_NO_FORMATTER) + "-" + meterId + "-" + alertType + "-" + objectId; } private MesEmQuotaDO mostSpecificQuota(List quotas) { if (CollUtil.isEmpty(quotas)) { return null; } return quotas.stream() .max(Comparator.comparing(MesEmQuotaDO::getObjectType) .thenComparing(MesEmQuotaDO::getId)) .orElse(null); } private Integer resolveObjectType(MesEmRecordDO record) { if (record.getMachineryId() != null) { return MesEmQuotaObjectTypeEnum.MACHINERY.getType(); } if (record.getLineId() != null) { return MesEmQuotaObjectTypeEnum.LINE.getType(); } if (record.getWorkshopId() != null) { return MesEmQuotaObjectTypeEnum.WORKSHOP.getType(); } return MesEmQuotaObjectTypeEnum.FACTORY.getType(); } private Long resolveObjectId(MesEmRecordDO record) { if (record.getMachineryId() != null) { return record.getMachineryId(); } if (record.getLineId() != null) { return record.getLineId(); } if (record.getWorkshopId() != null) { return record.getWorkshopId(); } return 0L; } private BigDecimal defaultIfNull(BigDecimal value, BigDecimal defaultValue) { return value == null ? defaultValue : value; } /** * 计算变化率(%),基数为 0 时返回 null */ private BigDecimal rate(BigDecimal current, BigDecimal base) { if (base == null || base.compareTo(BigDecimal.ZERO) == 0) { return null; } return current.subtract(base).multiply(HUNDRED).divide(base, 2, RoundingMode.HALF_UP); } private String stripZeros(BigDecimal value) { if (value == null) { return "0"; } return value.stripTrailingZeros().toPlainString(); } @Override public int checkRange(LocalDate begin, LocalDate end) { List records = recordMapper.selectListByDateRange(begin, end); records.forEach(this::checkRecord); return records.size(); } @Override public void deleteMeterAlerts(Long meterId, LocalDate alertDate) { alertMapper.deleteByMeterIdAndDate(meterId, alertDate); } @Override public MesEmAlertDO getAlert(Long id) { return alertMapper.selectById(id); } @Override public PageResult getAlertPage(MesEmAlertPageReqVO pageReqVO) { return alertMapper.selectPage(pageReqVO); } @Override public List getAlertRespVOList(List list) { if (CollUtil.isEmpty(list)) { return Collections.emptyList(); } return BeanUtils.toBean(list, MesEmAlertRespVO.class, vo -> { vo.setEnergyTypeName(MesEmEnergyTypeEnum.getName(vo.getEnergyType())); vo.setAlertTypeName(MesEmAlertTypeEnum.getName(vo.getAlertType())); vo.setAlertLevelName(MesEmAlertLevelEnum.getName(vo.getAlertLevel())); vo.setObjectTypeName(MesEmQuotaObjectTypeEnum.getName(vo.getObjectType())); vo.setStatusName(MesEmAlertStatusEnum.getName(vo.getStatus())); }); } @Override public void handleAlert(MesEmAlertHandleReqVO handleReqVO) { MesEmAlertDO alert = alertMapper.selectById(handleReqVO.getId()); if (alert == null) { throw exception(EM_ALERT_NOT_EXISTS); } if (MesEmAlertStatusEnum.HANDLED.getStatus().equals(alert.getStatus())) { throw exception(EM_ALERT_ALREADY_HANDLED); } alertMapper.updateById(new MesEmAlertDO().setId(alert.getId()) .setStatus(MesEmAlertStatusEnum.HANDLED.getStatus()) .setHandleUserId(SecurityFrameworkUtils.getLoginUserId()) .setHandleUserName(SecurityFrameworkUtils.getLoginUserNickname()) .setHandleTime(LocalDateTime.now()) .setHandleRemark(handleReqVO.getHandleRemark())); } }