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.MeterQuarterPoint;
|
import com.ruoyi.energymeter.mapper.EnergyMeterDeviceMapper;
|
import com.ruoyi.energymeter.pojo.EnergyMeterDevice;
|
import com.ruoyi.energymeter.provider.MeterProviderClient;
|
import com.ruoyi.energymeter.provider.MeterProviderException;
|
import com.ruoyi.energymeter.service.ElectricMeterDashboardService;
|
import com.ruoyi.energymeter.util.EnergyMeterSupport;
|
import com.ruoyi.energymeter.vo.ElectricMeterDashboardVO;
|
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.format.DateTimeFormatter;
|
import java.time.temporal.ChronoUnit;
|
import java.util.ArrayList;
|
import java.util.HashMap;
|
import java.util.List;
|
import java.util.Map;
|
import java.util.TreeMap;
|
import java.util.concurrent.ConcurrentHashMap;
|
|
/**
|
* 电表看板:把集社云 ElectricQuantity 的 mi15 数据聚合成今日曲线与电能质量。
|
*
|
* <p>15 分钟数据只在页面查看时实时拉取,不落库;日/月累计仍以采集表的 D 粒度为准。
|
* 只计算电量、功率和功率因数,不涉及任何费用。</p>
|
*/
|
@Slf4j
|
@Service
|
@RequiredArgsConstructor
|
public class ElectricMeterDashboardServiceImpl implements ElectricMeterDashboardService {
|
|
private static final DateTimeFormatter DAY_FORMAT = DateTimeFormatter.ofPattern("yyyy-MM-dd");
|
private static final DateTimeFormatter LABEL_FORMAT = DateTimeFormatter.ofPattern("MM-dd HH:mm");
|
private static final int SLOT_COUNT = 96;
|
/** 第三方限流较严,页面数据缓存 2 分钟,避免频繁切换设备/周次时反复请求。 */
|
private static final long CACHE_TTL_MS = 120_000L;
|
|
private final EnergyMeterDeviceMapper deviceMapper;
|
private final MeterProviderClient provider;
|
|
private final Map<String, CachedPoints> cache = new ConcurrentHashMap<>();
|
|
@Override
|
public ElectricMeterDashboardVO dashboard(Long meterId, Integer weekOffset) {
|
LocalDate today = EnergyMeterSupport.today();
|
int offset = weekOffset != null && weekOffset == 1 ? 1 : 0;
|
LocalDate qualityStart = offset == 1 ? today.minusDays(13) : today.minusDays(6);
|
LocalDate qualityEnd = offset == 1 ? today.minusDays(7) : today;
|
|
List<EnergyMeterDevice> devices = resolveDevices(meterId);
|
|
ElectricMeterDashboardVO vo = new ElectricMeterDashboardVO();
|
vo.setUnit(EnergyMeterConstants.UNIT_ELECTRIC);
|
vo.setMeterId(meterId);
|
vo.setDeviceCount(devices.size());
|
vo.setScopeLabel(scopeLabel(devices, meterId));
|
vo.setMockMode(provider.mockMode());
|
|
ElectricMeterDashboardVO.Today todayVo = new ElectricMeterDashboardVO.Today();
|
ElectricMeterDashboardVO.Quality qualityVo = new ElectricMeterDashboardVO.Quality();
|
|
if (devices.isEmpty()) {
|
fillEmptyToday(todayVo, today);
|
fillEmptyQuality(qualityVo, qualityStart, qualityEnd, offset);
|
vo.setToday(todayVo);
|
vo.setQuality(qualityVo);
|
return vo;
|
}
|
|
List<MeterQuarterPoint> qualityPoints;
|
List<MeterQuarterPoint> todayPoints;
|
if (offset == 1) {
|
// 上一周与今日没有交集,分别查询:今日曲线仍然取自最近两天
|
qualityPoints = queryDevices(devices, qualityStart, qualityEnd);
|
todayPoints = queryDevices(devices, today.minusDays(1), today);
|
} else {
|
// 近一周已包含昨天与今天,一次查询即可同时满足两块图表
|
qualityPoints = queryDevices(devices, qualityStart, qualityEnd);
|
todayPoints = qualityPoints;
|
}
|
|
fillToday(todayVo, today, todayPoints);
|
fillQuality(qualityVo, qualityStart, qualityEnd, offset, qualityPoints);
|
|
vo.setToday(todayVo);
|
vo.setQuality(qualityVo);
|
return vo;
|
}
|
|
/* ------------------------------------------------------------------ */
|
/* 今日曲线 */
|
/* ------------------------------------------------------------------ */
|
|
private void fillToday(ElectricMeterDashboardVO.Today todayVo, LocalDate today,
|
List<MeterQuarterPoint> points) {
|
Map<Integer, BigDecimal[]> todaySlots = new HashMap<>();
|
Map<Integer, BigDecimal[]> yesterdaySlots = new HashMap<>();
|
LocalDate yesterday = today.minusDays(1);
|
for (MeterQuarterPoint point : points) {
|
if (point.getTime() == null || point.getActive() == null) {
|
continue;
|
}
|
LocalDate day = point.getTime().toLocalDate();
|
Map<Integer, BigDecimal[]> target;
|
if (day.equals(today)) {
|
target = todaySlots;
|
} else if (day.equals(yesterday)) {
|
target = yesterdaySlots;
|
} else {
|
continue;
|
}
|
int slot = slotIndex(point.getTime());
|
BigDecimal[] values = target.computeIfAbsent(slot, key -> new BigDecimal[]{BigDecimal.ZERO, BigDecimal.ZERO});
|
values[0] = values[0].add(point.getActive());
|
values[1] = values[1].add(point.getReactive() == null ? BigDecimal.ZERO : point.getReactive());
|
}
|
|
todayVo.setDate(today.format(DAY_FORMAT));
|
todayVo.setRangeStart("00:00");
|
todayVo.setSeries(buildSlots(todaySlots, 1, SLOT_COUNT));
|
todayVo.setYesterdaySeries(buildSlots(yesterdaySlots, 1, SLOT_COUNT));
|
|
BigDecimal todayTotal = sumActive(todaySlots);
|
BigDecimal yesterdayTotal = sumActive(yesterdaySlots);
|
todayVo.setTotal(todaySlots.isEmpty() ? null : todayTotal);
|
todayVo.setYesterdayTotal(yesterdayTotal);
|
|
int lastSlot = lastDataSlot(todaySlots);
|
BigDecimal yesterdaySame = lastSlot <= 0 ? null : sumActive(yesterdaySlots, lastSlot);
|
todayVo.setYesterdaySamePeriodTotal(yesterdaySame);
|
if (lastSlot > 0 && yesterdaySame != null && yesterdaySame.compareTo(BigDecimal.ZERO) > 0) {
|
todayVo.setCompareRate(todayTotal.subtract(yesterdaySame)
|
.multiply(BigDecimal.valueOf(100))
|
.divide(yesterdaySame, 1, RoundingMode.HALF_UP));
|
}
|
todayVo.setRangeEnd(lastSlot > 0 ? labelOfSlot(lastSlot) : "--");
|
}
|
|
private List<ElectricMeterDashboardVO.QuarterPoint> buildSlots(Map<Integer, BigDecimal[]> slots,
|
int fromSlot, int toSlot) {
|
List<ElectricMeterDashboardVO.QuarterPoint> result = new ArrayList<>(toSlot - fromSlot + 1);
|
for (int slot = fromSlot; slot <= toSlot; slot++) {
|
ElectricMeterDashboardVO.QuarterPoint point = new ElectricMeterDashboardVO.QuarterPoint();
|
point.setTime(labelOfSlot(slot));
|
BigDecimal[] values = slots.get(slot);
|
if (values != null) {
|
point.setActive(values[0].setScale(3, RoundingMode.HALF_UP));
|
point.setReactive(values[1].setScale(3, RoundingMode.HALF_UP));
|
}
|
result.add(point);
|
}
|
return result;
|
}
|
|
private void fillEmptyToday(ElectricMeterDashboardVO.Today todayVo, LocalDate today) {
|
todayVo.setDate(today.format(DAY_FORMAT));
|
todayVo.setRangeStart("00:00");
|
todayVo.setRangeEnd("--");
|
todayVo.setSeries(buildSlots(Map.of(), 1, SLOT_COUNT));
|
todayVo.setYesterdaySeries(buildSlots(Map.of(), 1, SLOT_COUNT));
|
}
|
|
private BigDecimal sumActive(Map<Integer, BigDecimal[]> slots) {
|
return sumActive(slots, SLOT_COUNT);
|
}
|
|
private BigDecimal sumActive(Map<Integer, BigDecimal[]> slots, int maxSlot) {
|
BigDecimal total = BigDecimal.ZERO;
|
for (Map.Entry<Integer, BigDecimal[]> entry : slots.entrySet()) {
|
if (entry.getKey() <= maxSlot) {
|
total = total.add(entry.getValue()[0]);
|
}
|
}
|
return total.setScale(3, RoundingMode.HALF_UP);
|
}
|
|
private int lastDataSlot(Map<Integer, BigDecimal[]> slots) {
|
int last = 0;
|
for (Integer slot : slots.keySet()) {
|
if (slot > last) {
|
last = slot;
|
}
|
}
|
return last;
|
}
|
|
/** 时间片结束时间 → 1..96;24:00 记为第 96 片。 */
|
private int slotIndex(LocalDateTime time) {
|
int minute = time.getHour() * 60 + time.getMinute();
|
if (minute == 0) {
|
return SLOT_COUNT;
|
}
|
return Math.min(SLOT_COUNT, Math.max(1, (minute + 14) / 15));
|
}
|
|
private String labelOfSlot(int slot) {
|
if (slot >= SLOT_COUNT) {
|
return "23:59";
|
}
|
int minute = slot * 15;
|
return String.format("%02d:%02d", minute / 60, minute % 60);
|
}
|
|
/* ------------------------------------------------------------------ */
|
/* 电能质量 */
|
/* ------------------------------------------------------------------ */
|
|
private void fillQuality(ElectricMeterDashboardVO.Quality qualityVo, LocalDate start, LocalDate end,
|
int offset, List<MeterQuarterPoint> points) {
|
Map<LocalDateTime, BigDecimal[]> hourMap = new TreeMap<>();
|
for (MeterQuarterPoint point : points) {
|
if (point.getTime() == null || point.getActive() == null) {
|
continue;
|
}
|
LocalDate day = point.getTime().toLocalDate();
|
if (day.isBefore(start) || day.isAfter(end)) {
|
continue;
|
}
|
LocalDateTime hour = point.getTime().truncatedTo(ChronoUnit.HOURS);
|
BigDecimal[] values = hourMap.computeIfAbsent(hour,
|
key -> new BigDecimal[]{BigDecimal.ZERO, BigDecimal.ZERO, BigDecimal.ZERO});
|
values[0] = values[0].add(point.getActive());
|
values[1] = values[1].add(point.getReactive() == null ? BigDecimal.ZERO : point.getReactive());
|
// 记录该小时实际有几个 15 分钟点:最后一个小时可能还没走完,不能直接按 1 小时平均
|
values[2] = values[2].add(BigDecimal.ONE);
|
}
|
|
List<ElectricMeterDashboardVO.PowerPoint> series = new ArrayList<>(hourMap.size());
|
for (Map.Entry<LocalDateTime, BigDecimal[]> entry : hourMap.entrySet()) {
|
BigDecimal activeEnergy = entry.getValue()[0];
|
BigDecimal reactiveEnergy = entry.getValue()[1];
|
double hours = Math.max(entry.getValue()[2].doubleValue() * 0.25, 0.25);
|
double p = activeEnergy.doubleValue() / hours;
|
double q = reactiveEnergy.doubleValue() / hours;
|
ElectricMeterDashboardVO.PowerPoint powerPoint = new ElectricMeterDashboardVO.PowerPoint();
|
powerPoint.setTime(entry.getKey().format(LABEL_FORMAT));
|
powerPoint.setActivePower(BigDecimal.valueOf(p).setScale(3, RoundingMode.HALF_UP));
|
powerPoint.setReactivePower(BigDecimal.valueOf(q).setScale(3, RoundingMode.HALF_UP));
|
if (p > 0 || q > 0) {
|
double factor = p / Math.sqrt(p * p + q * q);
|
powerPoint.setPowerFactor(BigDecimal.valueOf(Math.min(factor, 1.0))
|
.setScale(3, RoundingMode.HALF_UP));
|
}
|
series.add(powerPoint);
|
}
|
|
qualityVo.setWeekStart(start.format(DAY_FORMAT));
|
qualityVo.setWeekEnd(end.format(DAY_FORMAT));
|
qualityVo.setLabel(offset == 1 ? "上一周" : "近一周");
|
qualityVo.setSeries(series);
|
}
|
|
private void fillEmptyQuality(ElectricMeterDashboardVO.Quality qualityVo, LocalDate start, LocalDate end,
|
int offset) {
|
qualityVo.setWeekStart(start.format(DAY_FORMAT));
|
qualityVo.setWeekEnd(end.format(DAY_FORMAT));
|
qualityVo.setLabel(offset == 1 ? "上一周" : "近一周");
|
qualityVo.setSeries(new ArrayList<>());
|
}
|
|
/* ------------------------------------------------------------------ */
|
/* 数据获取 */
|
/* ------------------------------------------------------------------ */
|
|
private List<MeterQuarterPoint> queryDevices(List<EnergyMeterDevice> devices, LocalDate start, LocalDate end) {
|
List<MeterQuarterPoint> all = new ArrayList<>();
|
for (EnergyMeterDevice device : devices) {
|
// 手工抄表设备没有 15 分钟数据,不发起第三方请求
|
if (EnergyMeterConstants.isManual(device.getSourceType())) {
|
continue;
|
}
|
try {
|
all.addAll(queryDevice(device, start, end));
|
} catch (MeterProviderException e) {
|
// 单台设备失败不影响其他设备;页面仍然展示已有数据
|
log.warn("电表看板查询 15 分钟数据失败 meterId={} {}~{}: {}",
|
device.getId(), start, end, e.getMessage());
|
}
|
}
|
return all;
|
}
|
|
private List<MeterQuarterPoint> queryDevice(EnergyMeterDevice device, LocalDate start, LocalDate end) {
|
String key = device.getId() + ":" + start + ":" + end;
|
CachedPoints cached = cache.get(key);
|
long now = System.currentTimeMillis();
|
if (cached != null && now - cached.at < CACHE_TTL_MS) {
|
return cached.points;
|
}
|
List<MeterQuarterPoint> points = provider.queryQuarterUsage(EnergyMeterConstants.TYPE_ELECTRIC,
|
device.getMeterAddress(), device.getProviderMeterId(), start, end);
|
// 第三方返回的通信地址必须与本地绑定一致,避免把别的表算进来
|
String expected = StringUtils.trim(device.getMeterAddress());
|
if (StringUtils.isNotBlank(expected)) {
|
points.removeIf(point -> StringUtils.isNotBlank(point.getAddress())
|
&& !expected.equals(StringUtils.trim(point.getAddress())));
|
}
|
if (cache.size() > 128) {
|
cache.clear();
|
}
|
cache.put(key, new CachedPoints(now, points));
|
return points;
|
}
|
|
private List<EnergyMeterDevice> resolveDevices(Long meterId) {
|
if (meterId != null) {
|
EnergyMeterDevice device = deviceMapper.selectById(meterId);
|
if (device == null) {
|
throw new ServiceException("设备不存在或已删除");
|
}
|
if (!EnergyMeterConstants.isElectric(device.getMeterType())) {
|
throw new ServiceException("该设备不是电表");
|
}
|
Long tenantId = EnergyMeterSupport.currentTenantId();
|
if (tenantId != null && device.getTenantId() != null && !tenantId.equals(device.getTenantId())) {
|
throw new ServiceException("无权访问该设备");
|
}
|
return List.of(device);
|
}
|
LambdaQueryWrapper<EnergyMeterDevice> wrapper = new LambdaQueryWrapper<>();
|
Long tenantId = EnergyMeterSupport.currentTenantId();
|
if (tenantId != null) {
|
wrapper.eq(EnergyMeterDevice::getTenantId, tenantId);
|
}
|
wrapper.eq(EnergyMeterDevice::getMeterType, EnergyMeterConstants.TYPE_ELECTRIC)
|
.eq(EnergyMeterDevice::getEnabled, 1)
|
.eq(EnergyMeterDevice::getBindStatus, EnergyMeterConstants.BIND_VERIFIED)
|
.orderByAsc(EnergyMeterDevice::getId);
|
return deviceMapper.selectList(wrapper);
|
}
|
|
private String scopeLabel(List<EnergyMeterDevice> devices, Long meterId) {
|
if (meterId != null && !devices.isEmpty()) {
|
EnergyMeterDevice device = devices.get(0);
|
return device.getDisplayName() + "(" + device.getMeterAddress() + ")";
|
}
|
return "全部已绑定电表(" + devices.size() + " 台)";
|
}
|
|
private record CachedPoints(long at, List<MeterQuarterPoint> points) {
|
}
|
}
|