package com.ruoyi.energymeter.service.impl;
|
|
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
import com.ruoyi.common.exception.ServiceException;
|
import com.ruoyi.common.utils.StringUtils;
|
import com.ruoyi.energymeter.constant.EnergyMeterConstants;
|
import com.ruoyi.energymeter.dto.DailyUsageRow;
|
import com.ruoyi.energymeter.dto.DataRangeRow;
|
import com.ruoyi.energymeter.dto.MeterUsageRow;
|
import com.ruoyi.energymeter.dto.MonthlyUsageRow;
|
import com.ruoyi.energymeter.mapper.EnergyMeterDeviceMapper;
|
import com.ruoyi.energymeter.mapper.EnergyMeterMeasurementMapper;
|
import com.ruoyi.energymeter.mapper.EnergyMeterSyncRunMapper;
|
import com.ruoyi.energymeter.pojo.EnergyMeterDevice;
|
import com.ruoyi.energymeter.pojo.EnergyMeterMeasurement;
|
import com.ruoyi.energymeter.pojo.EnergyMeterSyncRun;
|
import com.ruoyi.energymeter.provider.MeterProviderClient;
|
import com.ruoyi.energymeter.service.ElectricMeterDashboardService;
|
import com.ruoyi.energymeter.service.EnergyMonitorService;
|
import com.ruoyi.energymeter.util.EnergyMeterSupport;
|
import com.ruoyi.energymeter.vo.ElectricMeterDashboardVO;
|
import com.ruoyi.energymeter.vo.EnergyCollectionVO;
|
import com.ruoyi.energymeter.vo.EnergyRealtimeVO;
|
import com.ruoyi.energymeter.vo.EnergyReportVO;
|
import lombok.RequiredArgsConstructor;
|
import lombok.extern.slf4j.Slf4j;
|
import org.springframework.stereotype.Service;
|
|
import java.math.BigDecimal;
|
import java.math.RoundingMode;
|
import java.time.LocalDate;
|
import java.time.LocalDateTime;
|
import java.time.YearMonth;
|
import java.time.format.DateTimeFormatter;
|
import java.time.temporal.ChronoUnit;
|
import java.util.ArrayList;
|
import java.util.Comparator;
|
import java.util.HashMap;
|
import java.util.List;
|
import java.util.Map;
|
import java.util.Objects;
|
import java.util.stream.Collectors;
|
|
/**
|
* 能耗数据采集、实时监控与统计分析实现。
|
*
|
* <p>统计口径与用能概览保持一致:只汇总 D 粒度的用量指标,表码(POINT)不参与累加;
|
* 缺失日期保持 null,由前端显示为缺口而不是 0。</p>
|
*/
|
@Slf4j
|
@Service
|
@RequiredArgsConstructor
|
public class EnergyMonitorServiceImpl implements EnergyMonitorService {
|
|
private static final DateTimeFormatter DAY_FORMAT = DateTimeFormatter.ofPattern("yyyy-MM-dd");
|
private static final DateTimeFormatter DATETIME_FORMAT = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
|
private static final List<String> ALL_TYPES = List.of(EnergyMeterConstants.TYPE_WATER,
|
EnergyMeterConstants.TYPE_ELECTRIC, EnergyMeterConstants.TYPE_GAS);
|
private static final int TREND_DAYS = 7;
|
private static final int MAX_REPORT_DAYS = 366;
|
|
private final EnergyMeterDeviceMapper deviceMapper;
|
private final EnergyMeterMeasurementMapper measurementMapper;
|
private final EnergyMeterSyncRunMapper runMapper;
|
private final ElectricMeterDashboardService dashboardService;
|
private final MeterProviderClient provider;
|
|
/* ------------------------------------------------------------------ */
|
/* 数据采集 */
|
/* ------------------------------------------------------------------ */
|
|
@Override
|
public EnergyCollectionVO collection() {
|
LocalDate today = EnergyMeterSupport.today();
|
EnergyCollectionVO vo = new EnergyCollectionVO();
|
vo.setGeneratedAt(EnergyMeterSupport.now().format(DATETIME_FORMAT));
|
vo.setToday(today.format(DAY_FORMAT));
|
vo.setMockMode(provider.mockMode());
|
|
List<EnergyMeterSyncRun> runs = runMapper.selectList(new LambdaQueryWrapper<EnergyMeterSyncRun>()
|
.orderByDesc(EnergyMeterSyncRun::getId)
|
.last("LIMIT 8"));
|
int failed = 0;
|
int pending = 0;
|
for (EnergyMeterSyncRun run : runs) {
|
EnergyCollectionVO.RunItem item = toRunItem(run);
|
vo.getRuns().add(item);
|
failed += nvl(item.getFailedTasks());
|
pending += Math.max(0, nvl(item.getTotalTasks()) - nvl(item.getSuccessTasks())
|
- nvl(item.getEmptyTasks()) - nvl(item.getFailedTasks()));
|
}
|
vo.setFailedTaskCount(failed);
|
vo.setPendingTaskCount(pending);
|
|
for (String type : ALL_TYPES) {
|
List<EnergyMeterDevice> devices = loadDevices(type);
|
EnergyCollectionVO.Medium medium = new EnergyCollectionVO.Medium();
|
medium.setMeterType(type);
|
medium.setMeterName(mediumName(type));
|
medium.setUnit(EnergyMeterConstants.unit(type));
|
medium.setDeviceCount(devices.size());
|
|
int providerCount = 0;
|
int manualCount = 0;
|
for (EnergyMeterDevice device : devices) {
|
if (EnergyMeterConstants.isAutoCollect(device.getSourceType(), device.getSimulated())) {
|
providerCount++;
|
} else {
|
manualCount++;
|
}
|
}
|
medium.setProviderCount(providerCount);
|
medium.setManualCount(manualCount);
|
|
List<Long> ids = devices.stream().map(EnergyMeterDevice::getId).collect(Collectors.toList());
|
DataRangeRow range = ids.isEmpty() ? null
|
: measurementMapper.selectDataRange(ids, EnergyMeterConstants.usageMetric(type));
|
LocalDate dataStart = range == null || range.getMinTime() == null ? null : range.getMinTime().toLocalDate();
|
LocalDate dataEnd = range == null || range.getMaxTime() == null ? null : range.getMaxTime().toLocalDate();
|
medium.setDataStart(dataStart == null ? null : dataStart.format(DAY_FORMAT));
|
medium.setDataEnd(dataEnd == null ? null : dataEnd.format(DAY_FORMAT));
|
|
LocalDateTime lastSuccess = devices.stream()
|
.map(EnergyMeterDevice::getLastSuccessAt)
|
.filter(Objects::nonNull)
|
.max(LocalDateTime::compareTo)
|
.orElse(null);
|
medium.setLastSuccessAt(lastSuccess == null ? null : lastSuccess.format(DATETIME_FORMAT));
|
|
if (!ids.isEmpty() && dataEnd != null) {
|
Integer latestDayMeters = measurementMapper.countMetersWithUsage(ids,
|
EnergyMeterConstants.usageMetric(type), dataEnd.atStartOfDay(),
|
dataEnd.plusDays(1).atStartOfDay());
|
medium.setLatestDayMeters(latestDayMeters == null ? 0 : latestDayMeters);
|
}
|
|
Map<LocalDate, BigDecimal> daily = ids.isEmpty() ? Map.of() : toDayMap(measurementMapper.sumDailyUsage(ids,
|
EnergyMeterConstants.usageMetric(type), today.minusDays(TREND_DAYS - 1).atStartOfDay(),
|
today.plusDays(1).atStartOfDay()));
|
medium.setCoveredDays(daily.size());
|
medium.setExpectedDays(TREND_DAYS);
|
medium.setCoverageRate(daily.isEmpty() ? BigDecimal.ZERO
|
: BigDecimal.valueOf(daily.size() * 100.0 / TREND_DAYS).setScale(1, RoundingMode.HALF_UP));
|
|
// 在线口径:供应商标记在线,或最近 36 小时成功采集;模拟表再看最近数据日是否已生成
|
boolean simulatedFresh = dataEnd != null && !dataEnd.isBefore(today.minusDays(1));
|
int onlineCount = 0;
|
for (EnergyMeterDevice device : devices) {
|
if (isOnline(device)
|
|| (EnergyMeterConstants.isSimulated(device.getSimulated()) && simulatedFresh)) {
|
onlineCount++;
|
}
|
}
|
medium.setOnlineCount(onlineCount);
|
|
boolean manualOnly = !devices.isEmpty() && providerCount == 0;
|
boolean simulatedOnly = !devices.isEmpty()
|
&& devices.stream().allMatch(d -> EnergyMeterConstants.isSimulated(d.getSimulated()));
|
applyCollectionStatus(medium, devices.isEmpty(), dataEnd, lastSuccess, manualOnly, simulatedOnly, today);
|
vo.getMedia().add(medium);
|
}
|
return vo;
|
}
|
|
private EnergyCollectionVO.RunItem toRunItem(EnergyMeterSyncRun run) {
|
EnergyCollectionVO.RunItem item = new EnergyCollectionVO.RunItem();
|
item.setId(run.getId());
|
item.setRunKey(run.getRunKey());
|
item.setTriggerType(run.getTriggerType());
|
item.setTriggerTypeName(switch (StringUtils.defaultString(run.getTriggerType())) {
|
case EnergyMeterConstants.RUN_SCHEDULED -> "定时同步";
|
case EnergyMeterConstants.RUN_BACKFILL -> "历史补采";
|
default -> "手动采集";
|
});
|
item.setStatus(run.getStatus());
|
item.setStatusText(switch (StringUtils.defaultString(run.getStatus())) {
|
case EnergyMeterConstants.RUN_SUCCESS -> "全部成功";
|
case EnergyMeterConstants.RUN_PARTIAL -> "部分失败";
|
case EnergyMeterConstants.RUN_FAILED -> "采集失败";
|
case EnergyMeterConstants.RUN_RUNNING -> "采集中";
|
default -> "待执行";
|
});
|
item.setRangeStart(run.getRangeStart() == null ? null : run.getRangeStart().format(DAY_FORMAT));
|
item.setRangeEnd(run.getRangeEnd() == null ? null : run.getRangeEnd().format(DAY_FORMAT));
|
item.setTotalTasks(nvl(run.getTotalTasks()));
|
item.setSuccessTasks(nvl(run.getSuccessTasks()));
|
item.setEmptyTasks(nvl(run.getEmptyTasks()));
|
item.setFailedTasks(nvl(run.getFailedTasks()));
|
item.setStartedAt(run.getStartedAt() == null ? null : run.getStartedAt().format(DATETIME_FORMAT));
|
item.setFinishedAt(run.getFinishedAt() == null ? null : run.getFinishedAt().format(DATETIME_FORMAT));
|
return item;
|
}
|
|
private void applyCollectionStatus(EnergyCollectionVO.Medium medium, boolean empty, LocalDate dataEnd,
|
LocalDateTime lastSuccess, boolean manualOnly, boolean simulatedOnly,
|
LocalDate today) {
|
if (empty) {
|
medium.setStatus("NO_DEVICE");
|
medium.setStatusText("未接入设备");
|
return;
|
}
|
if (dataEnd == null) {
|
medium.setStatus("EMPTY");
|
medium.setStatusText("暂无采集数据");
|
return;
|
}
|
if (simulatedOnly) {
|
if (dataEnd.isBefore(today.minusDays(1))) {
|
medium.setStatus("PARTIAL");
|
medium.setStatusText("自动采集数据滞后 " + ChronoUnit.DAYS.between(dataEnd, today) + " 天");
|
} else {
|
medium.setStatus("OK");
|
medium.setStatusText("自动采集数据");
|
}
|
return;
|
}
|
if (manualOnly) {
|
if (dataEnd.isBefore(today.minusDays(1))) {
|
medium.setStatus("PARTIAL");
|
medium.setStatusText("数据滞后 " + ChronoUnit.DAYS.between(dataEnd, today) + " 天");
|
} else {
|
medium.setStatus("OK");
|
medium.setStatusText("手工抄表数据");
|
}
|
return;
|
}
|
if (lastSuccess == null || lastSuccess.isBefore(EnergyMeterSupport.now().minusHours(36))) {
|
medium.setStatus("STALE");
|
medium.setStatusText("超过 36 小时无成功采集");
|
return;
|
}
|
if (dataEnd.isBefore(today.minusDays(1))) {
|
medium.setStatus("PARTIAL");
|
medium.setStatusText("数据滞后 " + ChronoUnit.DAYS.between(dataEnd, today) + " 天");
|
return;
|
}
|
medium.setStatus("OK");
|
medium.setStatusText("采集正常");
|
}
|
|
/**
|
* 在线口径:供应商标记在线,或最近 36 小时内有成功采集。
|
*
|
* <p>第三方档案的在线标记只在绑定/验证时落库,长期不刷新,不能单独作为“在线”依据。</p>
|
*/
|
private boolean isOnline(EnergyMeterDevice device) {
|
if (Integer.valueOf(1).equals(device.getOnlineStatus())) {
|
return true;
|
}
|
return device.getLastSuccessAt() != null
|
&& device.getLastSuccessAt().isAfter(EnergyMeterSupport.now().minusHours(36));
|
}
|
|
/* ------------------------------------------------------------------ */
|
/* 实时监控 */
|
/* ------------------------------------------------------------------ */
|
|
@Override
|
public EnergyRealtimeVO realtime(Long meterId) {
|
LocalDate today = EnergyMeterSupport.today();
|
LocalDate windowStart = today.minusDays(TREND_DAYS);
|
LocalDate monthStart = today.withDayOfMonth(1);
|
|
EnergyRealtimeVO vo = new EnergyRealtimeVO();
|
vo.setGeneratedAt(EnergyMeterSupport.now().format(DATETIME_FORMAT));
|
vo.setToday(today.format(DAY_FORMAT));
|
vo.setMockMode(provider.mockMode());
|
vo.setMeterId(meterId);
|
|
List<EnergyMeterDevice> devices;
|
List<String> types;
|
if (meterId != null) {
|
EnergyMeterDevice device = requireDevice(meterId);
|
devices = List.of(device);
|
String type = EnergyMeterConstants.canonicalType(device.getMeterType());
|
if (type == null) {
|
throw new ServiceException("设备介质类型不合法");
|
}
|
types = List.of(type);
|
vo.setScopeLabel(device.getDisplayName() + "(" + device.getMeterAddress() + ")");
|
} else {
|
devices = loadDevices(null);
|
types = ALL_TYPES;
|
vo.setScopeLabel("全部已接入表具(" + devices.size() + " 台)");
|
}
|
|
Map<String, Map<LocalDate, BigDecimal>> dailyByType = new HashMap<>();
|
for (String type : types) {
|
List<EnergyMeterDevice> typeDevices = devices.stream()
|
.filter(d -> type.equals(d.getMeterType()))
|
.collect(Collectors.toList());
|
List<Long> ids = typeDevices.stream().map(EnergyMeterDevice::getId).collect(Collectors.toList());
|
|
Map<LocalDate, BigDecimal> windowDaily = ids.isEmpty() ? Map.of()
|
: toDayMap(measurementMapper.sumDailyUsage(ids, EnergyMeterConstants.usageMetric(type),
|
windowStart.atStartOfDay(), today.plusDays(1).atStartOfDay()));
|
dailyByType.put(type, windowDaily);
|
|
EnergyRealtimeVO.Medium medium = new EnergyRealtimeVO.Medium();
|
medium.setMeterType(type);
|
medium.setMeterName(mediumName(type));
|
medium.setUnit(EnergyMeterConstants.unit(type));
|
medium.setDeviceCount(typeDevices.size());
|
|
LocalDate latestDate = latestDate(windowDaily, today, windowStart);
|
BigDecimal latest = latestDate == null ? null : windowDaily.get(latestDate);
|
LocalDate previousDate = latestDate == null ? null
|
: latestDate(windowDaily, latestDate.minusDays(1), windowStart);
|
BigDecimal previous = previousDate == null ? null : windowDaily.get(previousDate);
|
|
boolean simulatedFresh = latestDate != null && !latestDate.isBefore(today.minusDays(1));
|
medium.setOnlineCount((int) typeDevices.stream()
|
.filter(d -> isOnline(d)
|
|| (EnergyMeterConstants.isSimulated(d.getSimulated()) && simulatedFresh))
|
.count());
|
|
medium.setLatestUsage(latest);
|
medium.setLatestUsageDate(latestDate == null ? null : latestDate.format(DAY_FORMAT));
|
medium.setPreviousUsage(previous);
|
medium.setPreviousUsageDate(previousDate == null ? null : previousDate.format(DAY_FORMAT));
|
medium.setCompareRate(compareRate(latest, previous));
|
|
BigDecimal monthTotal = null;
|
if (!ids.isEmpty()) {
|
Map<LocalDate, BigDecimal> monthDaily = toDayMap(measurementMapper.sumDailyUsage(ids,
|
EnergyMeterConstants.usageMetric(type), monthStart.atStartOfDay(),
|
today.plusDays(1).atStartOfDay()));
|
monthTotal = sumValues(monthDaily.values());
|
if (monthTotal != null) {
|
monthTotal = monthTotal.setScale(3, RoundingMode.HALF_UP);
|
}
|
}
|
medium.setMonthTotal(monthTotal);
|
|
List<BigDecimal> trendValues = new ArrayList<>();
|
for (int i = TREND_DAYS - 1; i >= 0; i--) {
|
BigDecimal value = windowDaily.get(today.minusDays(i));
|
if (value != null) {
|
trendValues.add(value);
|
}
|
}
|
medium.setAvgDaily(average(trendValues));
|
medium.setPeakDaily(trendValues.stream().max(BigDecimal::compareTo).orElse(null));
|
|
EnergyMeterMeasurement reading = null;
|
for (EnergyMeterDevice device : typeDevices) {
|
EnergyMeterMeasurement point = measurementMapper.selectLatestPoint(device.getId(),
|
EnergyMeterConstants.readingMetric(type));
|
if (point != null && (reading == null || point.getMeasureTime().isAfter(reading.getMeasureTime()))) {
|
reading = point;
|
}
|
}
|
if (reading != null) {
|
medium.setLatestReading(reading.getMetricValue());
|
medium.setLatestReadingTime(reading.getMeasureTime().format(DATETIME_FORMAT));
|
}
|
|
LocalDateTime lastSuccess = typeDevices.stream()
|
.map(EnergyMeterDevice::getLastSuccessAt)
|
.filter(Objects::nonNull)
|
.max(LocalDateTime::compareTo)
|
.orElse(null);
|
medium.setLastSuccessAt(lastSuccess == null ? null : lastSuccess.format(DATETIME_FORMAT));
|
|
boolean manualOnly = !typeDevices.isEmpty()
|
&& typeDevices.stream().noneMatch(d ->
|
EnergyMeterConstants.isAutoCollect(d.getSourceType(), d.getSimulated()));
|
boolean simulatedOnly = !typeDevices.isEmpty()
|
&& typeDevices.stream().allMatch(d -> EnergyMeterConstants.isSimulated(d.getSimulated()));
|
applyRealtimeStatus(medium, typeDevices.isEmpty(), latest, latestDate, lastSuccess,
|
manualOnly, simulatedOnly, today);
|
vo.getMedia().add(medium);
|
}
|
|
/* 近 7 日趋势 */
|
for (int i = TREND_DAYS - 1; i >= 0; i--) {
|
LocalDate day = today.minusDays(i);
|
EnergyRealtimeVO.TrendPoint point = new EnergyRealtimeVO.TrendPoint();
|
point.setDay(day.format(DAY_FORMAT));
|
point.setWater(dailyByType.getOrDefault(EnergyMeterConstants.TYPE_WATER, Map.of()).get(day));
|
point.setElectric(dailyByType.getOrDefault(EnergyMeterConstants.TYPE_ELECTRIC, Map.of()).get(day));
|
point.setGas(dailyByType.getOrDefault(EnergyMeterConstants.TYPE_GAS, Map.of()).get(day));
|
vo.getTrend().add(point);
|
}
|
|
/* 电表实时曲线 */
|
if (types.contains(EnergyMeterConstants.TYPE_ELECTRIC)) {
|
EnergyRealtimeVO.Electric electric = buildElectric(meterId);
|
vo.setElectric(electric);
|
if (electric != null) {
|
for (EnergyRealtimeVO.Medium medium : vo.getMedia()) {
|
if (EnergyMeterConstants.TYPE_ELECTRIC.equals(medium.getMeterType())) {
|
medium.setCurrentPower(electric.getCurrentPower());
|
medium.setPowerFactor(electric.getPowerFactor());
|
}
|
}
|
}
|
}
|
|
buildAlerts(vo, devices, today);
|
return vo;
|
}
|
|
private EnergyRealtimeVO.Electric buildElectric(Long meterId) {
|
ElectricMeterDashboardVO dashboard;
|
try {
|
dashboard = dashboardService.dashboard(meterId, 0);
|
} catch (Exception e) {
|
log.warn("实时监控查询电表 15 分钟数据失败:{}", e.getMessage());
|
return null;
|
}
|
EnergyRealtimeVO.Electric electric = new EnergyRealtimeVO.Electric();
|
electric.setRangeStart(dashboard.getToday() == null ? null : dashboard.getToday().getRangeStart());
|
electric.setRangeEnd(dashboard.getToday() == null ? null : dashboard.getToday().getRangeEnd());
|
electric.setTodayTotal(dashboard.getToday() == null ? null : dashboard.getToday().getTotal());
|
electric.setYesterdaySamePeriodTotal(dashboard.getToday() == null ? null
|
: dashboard.getToday().getYesterdaySamePeriodTotal());
|
|
List<ElectricMeterDashboardVO.QuarterPoint> points = dashboard.getToday() == null
|
? List.of() : dashboard.getToday().getSeries();
|
BigDecimal lastPower = null;
|
BigDecimal lastFactor = null;
|
for (ElectricMeterDashboardVO.QuarterPoint point : points) {
|
EnergyRealtimeVO.CurvePoint curvePoint = new EnergyRealtimeVO.CurvePoint();
|
curvePoint.setTime(point.getTime());
|
curvePoint.setActive(point.getActive());
|
if (point.getActive() == null) {
|
electric.getSeries().add(curvePoint);
|
continue;
|
}
|
// 15 分钟电量折算功率:kWh / 0.25h = kW
|
BigDecimal power = point.getActive().multiply(BigDecimal.valueOf(4)).setScale(3, RoundingMode.HALF_UP);
|
curvePoint.setPower(power);
|
electric.getSeries().add(curvePoint);
|
lastPower = power;
|
lastFactor = powerFactor(power, point.getReactive());
|
}
|
electric.setCurrentPower(lastPower);
|
electric.setPowerFactor(lastFactor);
|
return electric;
|
}
|
|
private BigDecimal powerFactor(BigDecimal activePower, BigDecimal reactiveEnergy) {
|
if (activePower == null) {
|
return null;
|
}
|
BigDecimal reactivePower = reactiveEnergy == null ? BigDecimal.ZERO
|
: reactiveEnergy.multiply(BigDecimal.valueOf(4));
|
double p = activePower.doubleValue();
|
double q = reactivePower.doubleValue();
|
if (p <= 0 && q <= 0) {
|
return null;
|
}
|
double factor = p / Math.sqrt(p * p + q * q);
|
return BigDecimal.valueOf(Math.min(factor, 1.0)).setScale(3, RoundingMode.HALF_UP);
|
}
|
|
private void applyRealtimeStatus(EnergyRealtimeVO.Medium medium, boolean noDevice, BigDecimal latest,
|
LocalDate latestDate, LocalDateTime lastSuccess, boolean manualOnly,
|
boolean simulatedOnly, LocalDate today) {
|
if (noDevice) {
|
medium.setStatus("NO_DEVICE");
|
medium.setStatusText("未接入设备");
|
return;
|
}
|
if (latest == null && medium.getLatestReading() == null) {
|
medium.setStatus("EMPTY");
|
medium.setStatusText("暂无数据");
|
return;
|
}
|
if (simulatedOnly) {
|
if (latestDate != null && latestDate.isBefore(today.minusDays(1))) {
|
medium.setStatus("STALE");
|
medium.setStatusText("自动采集数据滞后");
|
} else {
|
medium.setStatus("OK");
|
medium.setStatusText("自动采集数据");
|
}
|
return;
|
}
|
if (manualOnly) {
|
if (latestDate != null && latestDate.isBefore(today.minusDays(1))) {
|
medium.setStatus("STALE");
|
medium.setStatusText("抄表数据滞后");
|
} else {
|
medium.setStatus("OK");
|
medium.setStatusText("手工抄表数据");
|
}
|
return;
|
}
|
if (lastSuccess == null || lastSuccess.isBefore(EnergyMeterSupport.now().minusHours(36))) {
|
medium.setStatus("STALE");
|
medium.setStatusText("采集滞后");
|
return;
|
}
|
medium.setStatus("OK");
|
medium.setStatusText("运行正常");
|
}
|
|
private void buildAlerts(EnergyRealtimeVO vo, List<EnergyMeterDevice> devices, LocalDate today) {
|
List<EnergyRealtimeVO.Alert> alerts = vo.getAlerts();
|
for (EnergyRealtimeVO.Medium medium : vo.getMedia()) {
|
if ("STALE".equals(medium.getStatus()) || "EMPTY".equals(medium.getStatus())) {
|
alerts.add(alert("warning", medium.getMeterName() + ":" + medium.getStatusText()
|
+ ",请检查设备或手动采集"));
|
}
|
}
|
int deviceAlerts = 0;
|
for (EnergyMeterDevice device : devices) {
|
if (StringUtils.isNotBlank(device.getLastErrorCode()) && deviceAlerts < 3) {
|
String message = StringUtils.isBlank(device.getLastErrorMessage())
|
? device.getLastErrorCode() : device.getLastErrorMessage();
|
alerts.add(alert("warning", "「" + device.getDisplayName() + "」最近一次采集失败:" + message));
|
deviceAlerts++;
|
}
|
}
|
if (Boolean.TRUE.equals(vo.getMockMode())) {
|
alerts.add(alert("info", "当前为演示数据模式,采集结果由本地模拟生成,仅用于联调与页面演示。"));
|
}
|
if (alerts.size() > 6) {
|
vo.setAlerts(new ArrayList<>(alerts.subList(0, 6)));
|
}
|
}
|
|
private EnergyRealtimeVO.Alert alert(String level, String text) {
|
EnergyRealtimeVO.Alert alert = new EnergyRealtimeVO.Alert();
|
alert.setLevel(level);
|
alert.setText(text);
|
return alert;
|
}
|
|
/* ------------------------------------------------------------------ */
|
/* 统计分析 */
|
/* ------------------------------------------------------------------ */
|
|
@Override
|
public EnergyReportVO report(String startDate, String endDate, String granularity, String meterType,
|
String dimension) {
|
LocalDate today = EnergyMeterSupport.today();
|
LocalDate start = StringUtils.isBlank(startDate) ? today.withDayOfMonth(1) : parseDate(startDate, "开始日期");
|
LocalDate end = StringUtils.isBlank(endDate) ? today : parseDate(endDate, "结束日期");
|
if (end.isBefore(start)) {
|
throw new ServiceException("结束日期不能早于开始日期");
|
}
|
if (ChronoUnit.DAYS.between(start, end) > MAX_REPORT_DAYS) {
|
throw new ServiceException("统计跨度不能超过 " + MAX_REPORT_DAYS + " 天,请缩小时间范围");
|
}
|
|
String gran = "MONTH".equalsIgnoreCase(StringUtils.trim(granularity)) ? "MONTH" : "DAY";
|
String dim = "DEVICE".equalsIgnoreCase(StringUtils.trim(dimension)) ? "DEVICE" : "MEDIUM";
|
String filterType = StringUtils.isBlank(meterType) || "ALL".equalsIgnoreCase(meterType.trim())
|
? "ALL" : EnergyMeterConstants.canonicalType(meterType);
|
if (filterType == null) {
|
throw new ServiceException("能源类型只支持水表(WATER)、电表(ELECTRIC)或蒸汽表(GAS)");
|
}
|
List<String> types = "ALL".equals(filterType) ? ALL_TYPES : List.of(filterType);
|
|
EnergyReportVO vo = new EnergyReportVO();
|
vo.setGeneratedAt(EnergyMeterSupport.now().format(DATETIME_FORMAT));
|
vo.setMockMode(provider.mockMode());
|
vo.setStart(start.format(DAY_FORMAT));
|
vo.setEnd(end.format(DAY_FORMAT));
|
vo.setGranularity(gran);
|
vo.setDimension(dim);
|
vo.setMeterType(filterType);
|
|
List<String> periods = buildPeriods(start, end, gran);
|
vo.setPeriods(periods);
|
vo.setTotalPeriods(periods.size());
|
vo.setNote("统计口径:仅汇总日粒度用量,表码不参与累加;缺失周期按空值展示,不按 0 计算。");
|
|
List<EnergyMeterDevice> devices = loadDevices(null).stream()
|
.filter(d -> types.contains(d.getMeterType()))
|
.collect(Collectors.toList());
|
|
Map<String, Map<String, BigDecimal>> valuesByType = new HashMap<>();
|
for (String type : types) {
|
List<Long> ids = devices.stream()
|
.filter(d -> type.equals(d.getMeterType()))
|
.map(EnergyMeterDevice::getId)
|
.collect(Collectors.toList());
|
Map<String, BigDecimal> values = new HashMap<>();
|
if (!ids.isEmpty()) {
|
if ("MONTH".equals(gran)) {
|
for (MonthlyUsageRow row : measurementMapper.sumMonthlyUsage(ids,
|
EnergyMeterConstants.usageMetric(type), start.atStartOfDay(),
|
end.plusDays(1).atStartOfDay())) {
|
if (row.getMonth() != null && row.getTotal() != null) {
|
values.put(row.getMonth(), row.getTotal());
|
}
|
}
|
} else {
|
for (DailyUsageRow row : measurementMapper.sumDailyUsage(ids,
|
EnergyMeterConstants.usageMetric(type), start.atStartOfDay(),
|
end.plusDays(1).atStartOfDay())) {
|
if (row.getDay() != null && row.getTotal() != null) {
|
values.put(row.getDay(), row.getTotal());
|
}
|
}
|
}
|
}
|
valuesByType.put(type, values);
|
}
|
|
int coveredPeriods = 0;
|
for (String period : periods) {
|
EnergyReportVO.PeriodRow row = new EnergyReportVO.PeriodRow();
|
row.setPeriod(period);
|
boolean hasValue = false;
|
for (String type : types) {
|
BigDecimal value = valuesByType.get(type).get(period);
|
if (value != null) {
|
hasValue = true;
|
}
|
if (EnergyMeterConstants.isWater(type)) {
|
row.setWater(value);
|
} else if (EnergyMeterConstants.isElectric(type)) {
|
row.setElectric(value);
|
} else {
|
row.setGas(value);
|
}
|
}
|
if (hasValue) {
|
coveredPeriods++;
|
}
|
vo.getRows().add(row);
|
}
|
vo.setCoveredPeriods(coveredPeriods);
|
|
Map<String, BigDecimal> mediumTotals = new HashMap<>();
|
for (String type : types) {
|
BigDecimal total = sumValues(valuesByType.get(type).values());
|
mediumTotals.put(type, total);
|
EnergyReportVO.MediumTotal mediumTotal = new EnergyReportVO.MediumTotal();
|
mediumTotal.setMeterType(type);
|
mediumTotal.setMeterName(mediumName(type));
|
mediumTotal.setUnit(EnergyMeterConstants.unit(type));
|
mediumTotal.setTotal(total == null ? null : total.setScale(3, RoundingMode.HALF_UP));
|
mediumTotal.setDeviceCount((int) devices.stream()
|
.filter(d -> type.equals(d.getMeterType())).count());
|
vo.getTotals().add(mediumTotal);
|
}
|
|
for (String type : types) {
|
List<EnergyMeterDevice> typeDevices = devices.stream()
|
.filter(d -> type.equals(d.getMeterType()))
|
.collect(Collectors.toList());
|
if (typeDevices.isEmpty()) {
|
continue;
|
}
|
List<Long> ids = typeDevices.stream().map(EnergyMeterDevice::getId).collect(Collectors.toList());
|
Map<Long, BigDecimal> usageMap = new HashMap<>();
|
for (MeterUsageRow row : measurementMapper.sumUsageByMeter(ids, EnergyMeterConstants.usageMetric(type),
|
start.atStartOfDay(), end.plusDays(1).atStartOfDay())) {
|
if (row.getMeterId() != null && row.getTotal() != null) {
|
usageMap.put(row.getMeterId(), row.getTotal());
|
}
|
}
|
BigDecimal mediumTotal = mediumTotals.get(type);
|
for (EnergyMeterDevice device : typeDevices) {
|
EnergyReportVO.DeviceRow row = new EnergyReportVO.DeviceRow();
|
row.setMeterId(device.getId());
|
row.setMeterName(device.getDisplayName());
|
row.setMeterAddress(device.getMeterAddress());
|
row.setMeterType(type);
|
row.setMeterTypeName(mediumName(type));
|
row.setSourceTypeName(EnergyMeterConstants.isAutoCollect(device.getSourceType(), device.getSimulated())
|
? "自动采集" : "手工抄表");
|
row.setUnit(EnergyMeterConstants.unit(type));
|
BigDecimal total = usageMap.get(device.getId());
|
row.setTotal(total == null ? null : total.setScale(3, RoundingMode.HALF_UP));
|
if (total != null && mediumTotal != null && mediumTotal.compareTo(BigDecimal.ZERO) > 0) {
|
row.setShare(total.multiply(BigDecimal.valueOf(100))
|
.divide(mediumTotal, 1, RoundingMode.HALF_UP));
|
}
|
vo.getDevices().add(row);
|
}
|
}
|
vo.getDevices().sort(Comparator
|
.comparingInt((EnergyReportVO.DeviceRow row) -> typeOrder(row.getMeterType()))
|
.thenComparing(EnergyReportVO.DeviceRow::getTotal, Comparator.nullsLast(Comparator.reverseOrder()))
|
.thenComparing(EnergyReportVO.DeviceRow::getMeterId));
|
return vo;
|
}
|
|
private int typeOrder(String type) {
|
if (EnergyMeterConstants.isWater(type)) {
|
return 0;
|
}
|
if (EnergyMeterConstants.isElectric(type)) {
|
return 1;
|
}
|
return 2;
|
}
|
|
private List<String> buildPeriods(LocalDate start, LocalDate end, String gran) {
|
List<String> periods = new ArrayList<>();
|
if ("MONTH".equals(gran)) {
|
YearMonth month = YearMonth.from(start);
|
YearMonth last = YearMonth.from(end);
|
while (!month.isAfter(last)) {
|
periods.add(month.toString());
|
month = month.plusMonths(1);
|
}
|
} else {
|
for (LocalDate day = start; !day.isAfter(end); day = day.plusDays(1)) {
|
periods.add(day.format(DAY_FORMAT));
|
}
|
}
|
return periods;
|
}
|
|
/* ------------------------------------------------------------------ */
|
/* 工具 */
|
/* ------------------------------------------------------------------ */
|
|
private List<EnergyMeterDevice> loadDevices(String meterType) {
|
LambdaQueryWrapper<EnergyMeterDevice> wrapper = new LambdaQueryWrapper<>();
|
Long tenantId = EnergyMeterSupport.currentTenantId();
|
if (tenantId != null) {
|
wrapper.eq(EnergyMeterDevice::getTenantId, tenantId);
|
}
|
if (StringUtils.isNotBlank(meterType)) {
|
wrapper.eq(EnergyMeterDevice::getMeterType, meterType);
|
}
|
wrapper.eq(EnergyMeterDevice::getEnabled, 1)
|
.eq(EnergyMeterDevice::getBindStatus, EnergyMeterConstants.BIND_VERIFIED)
|
.orderByAsc(EnergyMeterDevice::getId);
|
return deviceMapper.selectList(wrapper);
|
}
|
|
private EnergyMeterDevice requireDevice(Long meterId) {
|
EnergyMeterDevice device = deviceMapper.selectById(meterId);
|
if (device == null) {
|
throw new ServiceException("设备不存在或已删除");
|
}
|
Long tenantId = EnergyMeterSupport.currentTenantId();
|
if (tenantId != null && device.getTenantId() != null && !tenantId.equals(device.getTenantId())) {
|
throw new ServiceException("无权访问该设备");
|
}
|
return device;
|
}
|
|
private String mediumName(String meterType) {
|
if (EnergyMeterConstants.isWater(meterType)) {
|
return "水表";
|
}
|
if (EnergyMeterConstants.isElectric(meterType)) {
|
return "电表";
|
}
|
if (EnergyMeterConstants.isGas(meterType)) {
|
return "蒸汽表";
|
}
|
return meterType;
|
}
|
|
private Map<LocalDate, BigDecimal> toDayMap(List<DailyUsageRow> rows) {
|
Map<LocalDate, BigDecimal> map = new HashMap<>();
|
for (DailyUsageRow row : rows) {
|
if (row.getDay() != null && row.getTotal() != null) {
|
map.put(LocalDate.parse(row.getDay()), row.getTotal());
|
}
|
}
|
return map;
|
}
|
|
private LocalDate latestDate(Map<LocalDate, BigDecimal> daily, LocalDate from, LocalDate floor) {
|
LocalDate day = from;
|
while (!day.isBefore(floor)) {
|
BigDecimal value = daily.get(day);
|
if (value != null) {
|
return day;
|
}
|
day = day.minusDays(1);
|
}
|
return null;
|
}
|
|
private BigDecimal compareRate(BigDecimal latest, BigDecimal previous) {
|
if (latest == null || previous == null || previous.compareTo(BigDecimal.ZERO) <= 0) {
|
return null;
|
}
|
return latest.subtract(previous)
|
.multiply(BigDecimal.valueOf(100))
|
.divide(previous, 1, RoundingMode.HALF_UP);
|
}
|
|
private BigDecimal sumValues(java.util.Collection<BigDecimal> values) {
|
BigDecimal sum = BigDecimal.ZERO;
|
boolean hasValue = false;
|
for (BigDecimal value : values) {
|
if (value != null) {
|
sum = sum.add(value);
|
hasValue = true;
|
}
|
}
|
return hasValue ? sum : null;
|
}
|
|
private BigDecimal average(List<BigDecimal> values) {
|
if (values == null || values.isEmpty()) {
|
return null;
|
}
|
BigDecimal sum = BigDecimal.ZERO;
|
for (BigDecimal value : values) {
|
sum = sum.add(value);
|
}
|
return sum.divide(BigDecimal.valueOf(values.size()), 3, RoundingMode.HALF_UP);
|
}
|
|
private int nvl(Integer value) {
|
return value == null ? 0 : value;
|
}
|
|
private LocalDate parseDate(String value, String label) {
|
try {
|
return LocalDate.parse(value.trim());
|
} catch (Exception e) {
|
throw new ServiceException(label + "格式应为 yyyy-MM-dd");
|
}
|
}
|
}
|