云
2026-10-25 d54fbc9d4f8981fa0f7f613b33a28a8cd4f764ea
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
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<MesEmQuotaDO> 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<MesEmQuotaDO> 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<MesEmQuotaDO> 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<MesEmRecordDO> 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<MesEmAlertDO> getAlertPage(MesEmAlertPageReqVO pageReqVO) {
        return alertMapper.selectPage(pageReqVO);
    }
 
    @Override
    public List<MesEmAlertRespVO> getAlertRespVOList(List<MesEmAlertDO> 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()));
    }
 
}