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;
}
}