package com.ruoyi.energymeter.service.impl; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.baomidou.mybatisplus.core.metadata.IPage; import com.baomidou.mybatisplus.extension.plugins.pagination.Page; 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.MonthlyUsageRow; import com.ruoyi.energymeter.mapper.EnergyMeterDeviceMapper; import com.ruoyi.energymeter.mapper.EnergyMeterMeasurementMapper; import com.ruoyi.energymeter.pojo.EnergyMeterDevice; import com.ruoyi.energymeter.pojo.EnergyMeterMeasurement; import com.ruoyi.energymeter.provider.MeterProviderClient; import com.ruoyi.energymeter.service.EnergyMeterStatService; import com.ruoyi.energymeter.util.EnergyMeterSupport; import com.ruoyi.energymeter.vo.EnergyMeterOverviewVO; 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.util.ArrayList; import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.stream.Collectors; /** * 概览统计实现。 * *

只从 D 粒度的 *_USAGE 指标汇总,绝不累加 POINT 表码;缺失日期保持 null,由前端显示缺口。

*/ @Slf4j @Service @RequiredArgsConstructor public class EnergyMeterStatServiceImpl implements EnergyMeterStatService { private static final DateTimeFormatter DATE_FORMAT = DateTimeFormatter.ofPattern("yyyy-MM-dd"); private final EnergyMeterDeviceMapper deviceMapper; private final EnergyMeterMeasurementMapper measurementMapper; private final MeterProviderClient provider; @Override public EnergyMeterOverviewVO overview(String meterType, Long meterId, String month, Integer year) { String type = normalizeType(meterType); YearMonth selectedMonth = parseMonth(month); int selectedYear = year == null ? selectedMonth.getYear() : year; if (selectedYear < 2000 || selectedYear > 2100) { throw new ServiceException("年份不合法"); } EnergyMeterOverviewVO vo = new EnergyMeterOverviewVO(); vo.setMeterType(type); vo.setMetricCode(EnergyMeterConstants.usageMetric(type)); vo.setUnit(EnergyMeterConstants.unit(type)); vo.setMeterId(meterId); vo.setMockMode(provider.mockMode()); vo.setSelectedMonth(selectedMonth.toString()); vo.setSelectedYear(String.valueOf(selectedYear)); vo.setMonthCost(null); vo.setYearCost(null); List devices = resolveDevices(type, meterId); vo.setDeviceCount(devices.size()); vo.setScopeLabel(buildScopeLabel(devices, meterId)); List meterIds = devices.stream().map(EnergyMeterDevice::getId).collect(Collectors.toList()); LocalDate today = EnergyMeterSupport.today(); LocalDate yesterday = today.minusDays(1); LocalDate monthStart = selectedMonth.atDay(1); LocalDate monthEndExclusive = selectedMonth.plusMonths(1).atDay(1); YearMonth previousMonth = selectedMonth.minusMonths(1); Map currentDaily = toDayMap(monthIdsEmpty(meterIds) ? List.of() : measurementMapper.sumDailyUsage( meterIds, vo.getMetricCode(), monthStart.atStartOfDay(), monthEndExclusive.atStartOfDay())); Map previousDaily = toDayMap(monthIdsEmpty(meterIds) ? List.of() : measurementMapper.sumDailyUsage( meterIds, vo.getMetricCode(), previousMonth.atDay(1).atStartOfDay(), monthStart.atStartOfDay())); int daysInMonth = selectedMonth.lengthOfMonth(); int daysInPreviousMonth = previousMonth.lengthOfMonth(); int cutDay = resolveCutDay(selectedMonth, today); List monthSeries = new ArrayList<>(daysInMonth); BigDecimal monthTotal = BigDecimal.ZERO; int collectedDays = 0; for (int day = 1; day <= daysInMonth; day++) { LocalDate date = monthStart.withDayOfMonth(day); BigDecimal currentValue = currentDaily.get(date); if (currentValue != null) { monthTotal = monthTotal.add(currentValue); if (day <= cutDay) { collectedDays++; } } EnergyMeterOverviewVO.MonthDayPoint point = new EnergyMeterOverviewVO.MonthDayPoint(); point.setDay(date.format(DATE_FORMAT)); point.setCurrent(currentValue); point.setPrevious(day <= daysInPreviousMonth ? previousDaily.get(previousMonth.atDay(day)) : null); monthSeries.add(point); } vo.setMonthSeries(monthSeries); boolean monthHasData = !currentDaily.isEmpty(); vo.setMonthHasData(monthHasData); vo.setMonthTotal(monthHasData ? monthTotal : null); BigDecimal previousTotal = BigDecimal.ZERO; int previousDayCount = 0; int compareDays = Math.min(cutDay, daysInPreviousMonth); for (int day = 1; day <= compareDays; day++) { BigDecimal value = previousDaily.get(previousMonth.atDay(day)); if (value != null) { previousTotal = previousTotal.add(value); previousDayCount++; } } vo.setMonthPreviousTotal(previousDayCount > 0 ? previousTotal : null); int expectedDays = cutDay; // 设备入网之前的日期不应计入缺口:从设备同步起点与月初的较大者开始算 if (!devices.isEmpty()) { LocalDate earliestSync = devices.stream() .map(EnergyMeterDevice::getSyncStartDate) .filter(java.util.Objects::nonNull) .min(LocalDate::compareTo) .orElse(null); if (earliestSync != null && earliestSync.isAfter(monthStart)) { int days = (int) java.time.temporal.ChronoUnit.DAYS.between(earliestSync, monthStart.plusDays(cutDay)); expectedDays = Math.max(0, Math.min(days, cutDay)); } } vo.setCollectedDays(collectedDays); vo.setExpectedDays(expectedDays); vo.setCoverageRate(expectedDays <= 0 ? BigDecimal.valueOf(100) : BigDecimal.valueOf(collectedDays * 100.0 / expectedDays).setScale(1, RoundingMode.HALF_UP)); java.time.LocalDate yearStart = LocalDate.of(selectedYear, 1, 1); java.time.LocalDate yearEndExclusive = yearStart.plusYears(1); java.time.LocalDate previousYearStart = yearStart.minusYears(1); Map currentMonthly = toMonthMap(monthIdsEmpty(meterIds) ? List.of() : measurementMapper.sumMonthlyUsage(meterIds, vo.getMetricCode(), yearStart.atStartOfDay(), yearEndExclusive.atStartOfDay())); Map previousMonthly = toMonthMap(monthIdsEmpty(meterIds) ? List.of() : measurementMapper.sumMonthlyUsage(meterIds, vo.getMetricCode(), previousYearStart.atStartOfDay(), yearStart.atStartOfDay())); boolean yearHasData = !currentMonthly.isEmpty(); vo.setYearHasData(yearHasData); int currentYear = today.getYear(); int cutMonth = selectedYear < currentYear ? 12 : (selectedYear > currentYear ? 0 : today.getMonthValue()); List yearSeries = new ArrayList<>(12); BigDecimal yearTotal = BigDecimal.ZERO; for (int m = 1; m <= 12; m++) { String key = selectedYear + "-" + String.format("%02d", m); String previousKey = (selectedYear - 1) + "-" + String.format("%02d", m); BigDecimal currentValue = currentMonthly.get(key); if (currentValue != null) { yearTotal = yearTotal.add(currentValue); } EnergyMeterOverviewVO.YearMonthPoint point = new EnergyMeterOverviewVO.YearMonthPoint(); point.setMonth(key); point.setCurrent(currentValue); point.setPrevious(previousMonthly.get(previousKey)); yearSeries.add(point); } vo.setYearSeries(yearSeries); vo.setYearTotal(yearHasData ? yearTotal : null); BigDecimal previousYearTotal = BigDecimal.ZERO; int previousMonthCount = 0; for (int m = 1; m <= cutMonth; m++) { BigDecimal value = previousMonthly.get((selectedYear - 1) + "-" + String.format("%02d", m)); if (value != null) { previousYearTotal = previousYearTotal.add(value); previousMonthCount++; } } vo.setYearPreviousTotal(previousMonthCount > 0 ? previousYearTotal : null); DataRangeRow range = monthIdsEmpty(meterIds) ? null : measurementMapper.selectDataRange(meterIds, vo.getMetricCode()); vo.setDataStart(range != null && range.getMinTime() != null ? range.getMinTime().toLocalDate().format(DATE_FORMAT) : null); vo.setDataEnd(range != null && range.getMaxTime() != null ? range.getMaxTime().toLocalDate().format(DATE_FORMAT) : null); vo.setDataThrough(yesterday.format(DATE_FORMAT)); LocalDateTime lastSuccess = devices.stream() .map(EnergyMeterDevice::getLastSuccessAt) .filter(java.util.Objects::nonNull) .max(LocalDateTime::compareTo) .orElse(null); vo.setLastSuccessAt(lastSuccess == null ? null : lastSuccess.format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"))); boolean hasData = monthHasData || yearHasData || range != null && range.getMaxTime() != null; vo.setHasData(hasData); long manualCount = devices.stream() .filter(d -> EnergyMeterConstants.isManual(d.getSourceType())) .count(); boolean manualOnly = !devices.isEmpty() && manualCount == devices.size(); int expectedForStatus = expectedDays; if (manualOnly) { // 手工抄表按“抄表期数”计,不按自然日算缺口 vo.setExpectedDays(collectedDays); vo.setCoverageRate(collectedDays > 0 ? BigDecimal.valueOf(100) : BigDecimal.ZERO); expectedForStatus = collectedDays; } applySyncStatus(vo, hasData, lastSuccess, collectedDays, expectedForStatus, monthHasData, yearHasData, manualOnly); return vo; } @Override public IPage pageMeasurements(Long meterId, String metricCode, String granularity, LocalDate start, LocalDate end, long current, long size) { EnergyMeterDevice device = requireDevice(meterId); long safeSize = Math.min(Math.max(size, 1), 200); Page page = new Page<>(Math.max(current, 1), safeSize); LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); wrapper.eq(EnergyMeterMeasurement::getMeterId, device.getId()); if (StringUtils.isNotBlank(metricCode)) { wrapper.eq(EnergyMeterMeasurement::getMetricCode, metricCode); } if (StringUtils.isNotBlank(granularity)) { wrapper.eq(EnergyMeterMeasurement::getGranularity, granularity); } if (start != null) { wrapper.ge(EnergyMeterMeasurement::getMeasureTime, start.atStartOfDay()); } if (end != null) { wrapper.lt(EnergyMeterMeasurement::getMeasureTime, end.plusDays(1).atStartOfDay()); } wrapper.orderByDesc(EnergyMeterMeasurement::getMeasureTime) .orderByDesc(EnergyMeterMeasurement::getId); IPage result = measurementMapper.selectPage(page, wrapper); result.getRecords().forEach(item -> { item.setMeterName(device.getDisplayName()); item.setMeterType(device.getMeterType()); item.setMeterAddress(device.getMeterAddress()); }); return result; } /* ------------------------------------------------------------------ */ private List resolveDevices(String meterType, Long meterId) { if (meterId != null) { EnergyMeterDevice device = requireDevice(meterId); if (!meterType.equals(device.getMeterType())) { throw new ServiceException("设备类型与当前能源类型不一致"); } return List.of(device); } LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); Long tenantId = EnergyMeterSupport.currentTenantId(); if (tenantId != null) { wrapper.eq(EnergyMeterDevice::getTenantId, tenantId); } wrapper.eq(EnergyMeterDevice::getMeterType, meterType) .eq(EnergyMeterDevice::getEnabled, 1) .eq(EnergyMeterDevice::getBindStatus, EnergyMeterConstants.BIND_VERIFIED) .orderByAsc(EnergyMeterDevice::getId); return deviceMapper.selectList(wrapper); } private EnergyMeterDevice requireDevice(Long meterId) { if (meterId == null) { throw new ServiceException("设备ID不能为空"); } 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 buildScopeLabel(List devices, Long meterId) { if (meterId != null && !devices.isEmpty()) { EnergyMeterDevice device = devices.get(0); return device.getDisplayName() + "(" + device.getMeterAddress() + ")"; } return "全部已绑定设备(" + devices.size() + " 台)"; } private void applySyncStatus(EnergyMeterOverviewVO vo, boolean hasData, LocalDateTime lastSuccess, int collectedDays, int expectedDays, boolean monthHasData, boolean yearHasData, boolean manualOnly) { String status; String text; if (manualOnly) { if (!monthHasData && !yearHasData) { status = "EMPTY"; text = "暂无抄表记录"; } else if (expectedDays > 0 && collectedDays < expectedDays) { status = "PARTIAL"; text = "抄表数据存在缺口(" + collectedDays + "/" + expectedDays + " 天)"; } else { status = "OK"; text = "手工抄表数据"; } vo.setSyncStatus(status); vo.setSyncStatusText(text); return; } if (!monthHasData && !yearHasData) { status = "EMPTY"; text = "暂无采集数据"; } else if (!hasData) { status = "EMPTY"; text = "暂无采集数据"; } else if (lastSuccess == null || lastSuccess.isBefore(EnergyMeterSupport.now().minusHours(36))) { status = "STALE"; text = "超过 36 小时无成功采集"; } else if (expectedDays > 0 && collectedDays < expectedDays) { status = "PARTIAL"; text = "数据存在缺口(" + collectedDays + "/" + expectedDays + " 天)"; } else { status = "OK"; text = "数据完整"; } vo.setSyncStatus(status); vo.setSyncStatusText(text); } private int resolveCutDay(YearMonth month, LocalDate today) { YearMonth currentMonth = YearMonth.from(today); if (month.isAfter(currentMonth)) { return 0; } if (month.equals(currentMonth)) { return Math.max(today.minusDays(1).getDayOfMonth(), 0); } return month.lengthOfMonth(); } private Map toDayMap(List rows) { Map 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 Map toMonthMap(List rows) { Map map = new HashMap<>(); for (MonthlyUsageRow row : rows) { if (row.getMonth() != null && row.getTotal() != null) { map.put(row.getMonth(), row.getTotal()); } } return map; } private boolean monthIdsEmpty(List ids) { return ids == null || ids.isEmpty(); } private YearMonth parseMonth(String month) { if (StringUtils.isBlank(month)) { return YearMonth.now(EnergyMeterConstants.ZONE); } try { return YearMonth.parse(month.trim()); } catch (Exception e) { throw new ServiceException("月份格式应为 yyyy-MM"); } } private String normalizeType(String meterType) { String type = EnergyMeterConstants.canonicalType(meterType); if (type == null) { throw new ServiceException("能源类型只支持水表(WATER)、电表(ELECTRIC)或气表(GAS)"); } return type; } }