Browse Source

新增能源统计与电表数据查询、导出接口。

Co-authored-by: Cursor <cursoragent@cursor.com>
fuyuchuan 1 day ago
parent
commit
3ea1e4e776

+ 108 - 11
service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/controller/web/AnalyticsController.java

@@ -2,50 +2,147 @@ package com.usky.vpp.controller.web;
 
 import com.usky.common.core.bean.ApiResult;
 import com.usky.vpp.service.VppAnalyticsService;
+import com.usky.vpp.service.VppEnergyStatService;
+import com.usky.vpp.service.vo.EnergyStatPageVO;
+import com.usky.vpp.service.vo.MeterDataVO;
 import org.springframework.beans.factory.annotation.Autowired;
-import org.springframework.web.bind.annotation.*;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RequestParam;
+import org.springframework.web.bind.annotation.RestController;
+
+import javax.servlet.http.HttpServletResponse;
+import java.time.LocalDateTime;
+import java.time.format.DateTimeFormatter;
 
 /**
- * 虚拟电厂 - Analytics 接口
- * 网关前缀: /prod-api/service-vpp
+ * 虚拟电厂 - 统计分析接口
+ * <p>网关前缀: /prod-api/service-vpp</p>
  */
 @RestController
 @RequestMapping("/analytics")
 public class AnalyticsController {
 
+    private static final DateTimeFormatter TIME_FMT = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
+
     @Autowired
     private VppAnalyticsService vppAnalyticsService;
+    @Autowired
+    private VppEnergyStatService vppEnergyStatService;
 
+    /**
+     * 能源统计分页列表(含 KPI 汇总)
+     * <ul>
+     *   <li>customerName 客户名称 模糊匹配</li>
+     *   <li>siteName     站点名称 模糊匹配</li>
+     *   <li>deviceName   设备名称 模糊匹配</li>
+     *   <li>startTime    开始时间 yyyy-MM-dd HH:mm:ss,默认当天 00:00:00</li>
+     *   <li>endTime      结束时间 yyyy-MM-dd HH:mm:ss,默认当天 23:59:59</li>
+     *   <li>current      页码,默认 1</li>
+     *   <li>size         页大小,默认 20</li>
+     * </ul>
+     */
     @GetMapping(value = "/energy")
-    public ApiResult<Object> energyReport(@RequestParam(required = false) java.util.Map<String, Object> params) {
-        return ApiResult.success(null);
+    public ApiResult<EnergyStatPageVO> energyReport(
+            @RequestParam(value = "customerName", required = false) String customerName,
+            @RequestParam(value = "siteName", required = false) String siteName,
+            @RequestParam(value = "deviceName", required = false) String deviceName,
+            @RequestParam(value = "startTime", required = false) String startTime,
+            @RequestParam(value = "endTime", required = false) String endTime,
+            @RequestParam(value = "current", required = false, defaultValue = "1") Integer current,
+            @RequestParam(value = "size", required = false, defaultValue = "20") Integer size) {
+        return ApiResult.success(vppEnergyStatService.pageEnergy(
+                customerName, siteName, deviceName, parseTime(startTime), parseTime(endTime), current, size));
     }
+
+    /**
+     * 导出能源统计列表(Excel)
+     */
+    @GetMapping(value = "/energy/export")
+    public void exportEnergy(
+            @RequestParam(value = "customerName", required = false) String customerName,
+            @RequestParam(value = "siteName", required = false) String siteName,
+            @RequestParam(value = "deviceName", required = false) String deviceName,
+            @RequestParam(value = "startTime", required = false) String startTime,
+            @RequestParam(value = "endTime", required = false) String endTime,
+            @RequestParam(value = "fileName", required = false) String fileName,
+            HttpServletResponse response) {
+        vppEnergyStatService.exportEnergy(
+                customerName, siteName, deviceName, parseTime(startTime), parseTime(endTime), fileName, response);
+    }
+
+    /**
+     * 电表数据详情
+     * <p>链路:vpp_device.uuid → dmp_device.product_id → dmp_product_attribute.attribute_code → TSDB 历史。</p>
+     * <ul>
+     *   <li>deviceId   设备主键(vpp_device.id)</li>
+     *   <li>startTime  查询开始时间,默认当天 00:00:00</li>
+     *   <li>endTime    查询结束时间,默认当天 23:59:59</li>
+     * </ul>
+     */
+    @GetMapping(value = "/energy/meter")
+    public ApiResult<MeterDataVO> meterData(
+            @RequestParam("deviceId") Long deviceId,
+            @RequestParam(value = "startTime", required = false) String startTime,
+            @RequestParam(value = "endTime", required = false) String endTime) {
+        return ApiResult.success(vppEnergyStatService.getMeterData(deviceId, parseTime(startTime), parseTime(endTime)));
+    }
+
+    /**
+     * 导出电表数据(Excel)
+     * <p>表头为 attribute_code(attribute_name/unit),同时包含英文标识与中文解释。</p>
+     */
+    @GetMapping(value = "/energy/meter/export")
+    public void exportMeterData(
+            @RequestParam("deviceId") Long deviceId,
+            @RequestParam(value = "startTime", required = false) String startTime,
+            @RequestParam(value = "endTime", required = false) String endTime,
+            @RequestParam(value = "fileName", required = false) String fileName,
+            HttpServletResponse response) {
+        vppEnergyStatService.exportMeterData(deviceId, parseTime(startTime), parseTime(endTime), fileName, response);
+    }
+
     @GetMapping(value = "/monthly")
     public ApiResult<Object> monthlyReport(@RequestParam(required = false) java.util.Map<String, Object> params) {
-        return ApiResult.success(null);
+        return ApiResult.success(vppAnalyticsService.stub("monthly", params));
     }
+
     @GetMapping(value = "/settlement")
     public ApiResult<Object> settlementReport(@RequestParam(required = false) java.util.Map<String, Object> params) {
-        return ApiResult.success(null);
+        return ApiResult.success(vppAnalyticsService.stub("settlement", params));
     }
+
     @GetMapping(value = "/dr-settlement")
     public ApiResult<Object> drSettlementReport(@RequestParam(required = false) java.util.Map<String, Object> params) {
-        return ApiResult.success(null);
+        return ApiResult.success(vppAnalyticsService.stub("dr-settlement", params));
     }
+
     @GetMapping(value = "/total-energy")
     public ApiResult<Object> totalEnergy(@RequestParam(required = false) java.util.Map<String, Object> params) {
-        return ApiResult.success(null);
+        return ApiResult.success(vppAnalyticsService.stub("total-energy", params));
     }
+
     @GetMapping(value = "/time-of-use")
     public ApiResult<Object> timeOfUse(@RequestParam(required = false) java.util.Map<String, Object> params) {
-        return ApiResult.success(null);
+        return ApiResult.success(vppAnalyticsService.stub("time-of-use", params));
     }
+
     @GetMapping(value = "/compare")
     public ApiResult<Object> compare(@RequestParam(required = false) java.util.Map<String, Object> params) {
-        return ApiResult.success(null);
+        return ApiResult.success(vppAnalyticsService.stub("compare", params));
     }
+
     @PostMapping(value = "/export")
     public ApiResult<Object> export(@RequestBody(required = false) Object body) {
         return ApiResult.success(null);
     }
+
+    private static LocalDateTime parseTime(String value) {
+        if (value == null || value.trim().isEmpty()) {
+            return null;
+        }
+        return LocalDateTime.parse(value.trim(), TIME_FMT);
+    }
 }

+ 39 - 0
service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/domain/DmpDevice.java

@@ -0,0 +1,39 @@
+package com.usky.vpp.domain;
+
+import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableName;
+import lombok.Data;
+import lombok.EqualsAndHashCode;
+
+import java.io.Serializable;
+
+/**
+ * 物联网设备表(dmp_device)
+ */
+@Data
+@EqualsAndHashCode(callSuper = false)
+@TableName("dmp_device")
+public class DmpDevice implements Serializable {
+
+    private static final long serialVersionUID = 1L;
+
+    @TableId(value = "id", type = IdType.AUTO)
+    private Integer id;
+
+    private String deviceId;
+
+    private String deviceName;
+
+    private Integer deviceType;
+
+    private Integer productId;
+
+    private String productCode;
+
+    private String deviceUuid;
+
+    private Integer deleteFlag;
+
+    private Integer tenantId;
+}

+ 43 - 0
service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/domain/DmpProductAttribute.java

@@ -0,0 +1,43 @@
+package com.usky.vpp.domain;
+
+import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableName;
+import lombok.Data;
+import lombok.EqualsAndHashCode;
+
+import java.io.Serializable;
+
+/**
+ * 产品属性表(dmp_product_attribute)
+ */
+@Data
+@EqualsAndHashCode(callSuper = false)
+@TableName("dmp_product_attribute")
+public class DmpProductAttribute implements Serializable {
+
+    private static final long serialVersionUID = 1L;
+
+    @TableId(value = "id", type = IdType.AUTO)
+    private Integer id;
+
+    private Integer productId;
+
+    /** 属性名称(中文解释) */
+    private String attributeName;
+
+    /** 属性标识(英文,对应 TSDB 字段) */
+    private String attributeCode;
+
+    /** 单位 */
+    private String attributeUnit;
+
+    /** 描述 */
+    private String attributeDescribe;
+
+    private Integer bindStatus;
+
+    private Integer deleteFlag;
+
+    private Integer tenantId;
+}

+ 9 - 0
service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/mapper/DmpDeviceMapper.java

@@ -0,0 +1,9 @@
+package com.usky.vpp.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.usky.vpp.domain.DmpDevice;
+import org.apache.ibatis.annotations.Mapper;
+
+@Mapper
+public interface DmpDeviceMapper extends BaseMapper<DmpDevice> {
+}

+ 9 - 0
service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/mapper/DmpProductAttributeMapper.java

@@ -0,0 +1,9 @@
+package com.usky.vpp.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.usky.vpp.domain.DmpProductAttribute;
+import org.apache.ibatis.annotations.Mapper;
+
+@Mapper
+public interface DmpProductAttributeMapper extends BaseMapper<DmpProductAttribute> {
+}

+ 38 - 0
service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/service/VppEnergyStatService.java

@@ -0,0 +1,38 @@
+package com.usky.vpp.service;
+
+import com.usky.vpp.service.vo.EnergyStatPageVO;
+import com.usky.vpp.service.vo.MeterDataVO;
+
+import javax.servlet.http.HttpServletResponse;
+import java.time.LocalDateTime;
+
+/**
+ * 能源统计 / 电表数据
+ */
+public interface VppEnergyStatService {
+
+    /**
+     * 能源统计分页列表(含 KPI 汇总)
+     */
+    EnergyStatPageVO pageEnergy(String customerName, String siteName, String deviceName,
+                                LocalDateTime startTime, LocalDateTime endTime,
+                                Integer current, Integer size);
+
+    /**
+     * 导出能源统计列表
+     */
+    void exportEnergy(String customerName, String siteName, String deviceName,
+                      LocalDateTime startTime, LocalDateTime endTime,
+                      String fileName, HttpServletResponse response);
+
+    /**
+     * 电表数据详情(产品属性 → TSDB 历史)
+     */
+    MeterDataVO getMeterData(Long deviceId, LocalDateTime startTime, LocalDateTime endTime);
+
+    /**
+     * 导出电表数据(标题含英文标识与中文解释)
+     */
+    void exportMeterData(Long deviceId, LocalDateTime startTime, LocalDateTime endTime,
+                         String fileName, HttpServletResponse response);
+}

+ 847 - 0
service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/service/impl/VppEnergyStatServiceImpl.java

@@ -0,0 +1,847 @@
+package com.usky.vpp.service.impl;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.usky.common.core.bean.CommonPage;
+import com.usky.common.core.exception.BusinessException;
+import com.usky.common.security.utils.SecurityUtils;
+import com.usky.vpp.constant.VppDrEventStatus;
+import com.usky.vpp.constant.VppDrEventType;
+import com.usky.vpp.constant.VppTsdbConstants;
+import com.usky.vpp.domain.DmpDevice;
+import com.usky.vpp.domain.DmpProductAttribute;
+import com.usky.vpp.domain.VppCustomer;
+import com.usky.vpp.domain.VppDevice;
+import com.usky.vpp.domain.VppDrEvaluation;
+import com.usky.vpp.domain.VppDrEvent;
+import com.usky.vpp.domain.VppDrInvitation;
+import com.usky.vpp.domain.VppSite;
+import com.usky.vpp.mapper.DmpDeviceMapper;
+import com.usky.vpp.mapper.DmpProductAttributeMapper;
+import com.usky.vpp.mapper.VppCustomerMapper;
+import com.usky.vpp.mapper.VppDeviceMapper;
+import com.usky.vpp.mapper.VppDrEvaluationMapper;
+import com.usky.vpp.mapper.VppDrEventMapper;
+import com.usky.vpp.mapper.VppDrInvitationMapper;
+import com.usky.vpp.mapper.VppSiteMapper;
+import com.usky.vpp.service.VppEnergyStatService;
+import com.usky.vpp.service.VppTsdbQueryService;
+import com.usky.vpp.service.vo.EnergyStatItemVO;
+import com.usky.vpp.service.vo.EnergyStatPageVO;
+import com.usky.vpp.service.vo.EnergyStatSummaryVO;
+import com.usky.vpp.service.vo.MeterDataVO;
+import com.usky.vpp.util.VppAuditHelper;
+import com.usky.vpp.util.VppEnergyUsageHelper;
+import org.apache.poi.ss.usermodel.FillPatternType;
+import org.apache.poi.ss.usermodel.IndexedColors;
+import org.apache.poi.xssf.usermodel.XSSFCell;
+import org.apache.poi.xssf.usermodel.XSSFCellStyle;
+import org.apache.poi.xssf.usermodel.XSSFFont;
+import org.apache.poi.xssf.usermodel.XSSFRow;
+import org.apache.poi.xssf.usermodel.XSSFSheet;
+import org.apache.poi.xssf.usermodel.XSSFWorkbook;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.stereotype.Service;
+import org.springframework.util.CollectionUtils;
+import org.springframework.util.StringUtils;
+
+import javax.servlet.http.HttpServletResponse;
+import java.io.IOException;
+import java.math.BigDecimal;
+import java.math.RoundingMode;
+import java.net.URLEncoder;
+import java.nio.charset.StandardCharsets;
+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.Arrays;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import java.util.Set;
+import java.util.TreeMap;
+import java.util.TreeSet;
+import java.util.stream.Collectors;
+
+/**
+ * 能源统计:设备用电量来自 TSDB 累计电能差值;削峰/填谷按站点邀约实际响应量×时长分摊到设备。
+ * 电表详情:vpp_device.uuid → dmp_device.product_id → dmp_product_attribute.attribute_code → TSDB 历史。
+ */
+@Service
+public class VppEnergyStatServiceImpl implements VppEnergyStatService {
+
+    private static final Logger log = LoggerFactory.getLogger(VppEnergyStatServiceImpl.class);
+    private static final DateTimeFormatter TIME_FMT = DateTimeFormatter.ofPattern(VppTsdbConstants.TIME_FORMAT);
+    private static final int SCALE = 4;
+    private static final int DEFAULT_CURRENT = 1;
+    private static final int DEFAULT_SIZE = 20;
+    private static final int MAX_SIZE = 100;
+    private static final List<String> POWER_METRIC_PRIORITY = buildPowerMetricPriority();
+    private static final List<String> ENERGY_METRICS = Collections.singletonList(VppTsdbConstants.METRIC_EPP);
+
+    @Autowired
+    private VppDeviceMapper deviceMapper;
+    @Autowired
+    private VppSiteMapper siteMapper;
+    @Autowired
+    private VppCustomerMapper customerMapper;
+    @Autowired
+    private VppDrEventMapper eventMapper;
+    @Autowired
+    private VppDrInvitationMapper invitationMapper;
+    @Autowired
+    private VppDrEvaluationMapper evaluationMapper;
+    @Autowired
+    private DmpDeviceMapper dmpDeviceMapper;
+    @Autowired
+    private DmpProductAttributeMapper dmpProductAttributeMapper;
+    @Autowired
+    private VppTsdbQueryService tsdbQueryService;
+
+    @Override
+    public EnergyStatPageVO pageEnergy(String customerName, String siteName, String deviceName,
+                                       LocalDateTime startTime, LocalDateTime endTime,
+                                       Integer current, Integer size) {
+        LocalDateTime[] range = resolveTimeRange(startTime, endTime);
+        List<EnergyStatItemVO> items = listEnergyItems(customerName, siteName, deviceName, range[0], range[1]);
+        int pageNum = current == null || current < 1 ? DEFAULT_CURRENT : current;
+        int pageSize = size == null || size < 1 ? DEFAULT_SIZE : Math.min(size, MAX_SIZE);
+
+        EnergyStatPageVO result = new EnergyStatPageVO();
+        result.setSummary(buildSummary(items));
+        result.setPage(paginate(items, pageNum, pageSize));
+        return result;
+    }
+
+    @Override
+    public void exportEnergy(String customerName, String siteName, String deviceName,
+                             LocalDateTime startTime, LocalDateTime endTime,
+                             String fileName, HttpServletResponse response) {
+        LocalDateTime[] range = resolveTimeRange(startTime, endTime);
+        List<EnergyStatItemVO> items = listEnergyItems(customerName, siteName, deviceName, range[0], range[1]);
+        String name = StringUtils.hasText(fileName) ? fileName.trim() : "能源统计";
+        writeEnergyExcel(name, items, response);
+    }
+
+    @Override
+    public MeterDataVO getMeterData(Long deviceId, LocalDateTime startTime, LocalDateTime endTime) {
+        LocalDateTime[] range = resolveTimeRange(startTime, endTime);
+        return loadMeterData(requireDevice(deviceId), range[0], range[1]);
+    }
+
+    @Override
+    public void exportMeterData(Long deviceId, LocalDateTime startTime, LocalDateTime endTime,
+                                String fileName, HttpServletResponse response) {
+        MeterDataVO meterData = getMeterData(deviceId, startTime, endTime);
+        String name = StringUtils.hasText(fileName)
+                ? fileName.trim()
+                : (StringUtils.hasText(meterData.getDeviceName())
+                ? meterData.getDeviceName() + "-电表数据" : "电表数据");
+        writeMeterExcel(name, meterData, response);
+    }
+
+    // ---------- 能源统计 ----------
+
+    private List<EnergyStatItemVO> listEnergyItems(String customerName, String siteName, String deviceName,
+                                                   LocalDateTime startTime, LocalDateTime endTime) {
+        List<VppDevice> devices = listMatchingDevices(customerName, siteName, deviceName);
+        if (devices.isEmpty()) {
+            return Collections.emptyList();
+        }
+
+        Map<Long, VppSite> siteMap = loadSiteMap(devices);
+        Map<Long, VppCustomer> customerMap = loadCustomerMap(siteMap.values());
+        Map<Long, PeakValley> peakValleyMap = loadPeakValleyByDevice(devices, startTime, endTime);
+        Map<String, BigDecimal> energyByUuid = loadRegulatedEnergy(devices, startTime, endTime);
+
+        List<EnergyStatItemVO> items = new ArrayList<>(devices.size());
+        for (VppDevice device : devices) {
+            VppSite site = device.getSiteId() == null ? null : siteMap.get(device.getSiteId());
+            VppCustomer customer = site == null || site.getCustomerId() == null
+                    ? null : customerMap.get(site.getCustomerId());
+            PeakValley peakValley = peakValleyMap.getOrDefault(device.getId(), new PeakValley());
+            EnergyStatItemVO item = new EnergyStatItemVO();
+            item.setDeviceId(device.getId());
+            item.setDeviceUuid(device.getDeviceUuid());
+            item.setDeviceName(device.getDeviceName());
+            item.setSiteId(device.getSiteId());
+            item.setSiteName(site == null ? null : site.getSiteName());
+            item.setCustomerId(customer == null ? null : customer.getId());
+            item.setCustomerName(customer == null ? null : customer.getCustomerName());
+            item.setRegulatedEnergyKwh(nz(energyByUuid.get(trimUuid(device.getDeviceUuid()))));
+            item.setPeakShaveKwh(peakValley.peak);
+            item.setValleyFillKwh(peakValley.valley);
+            items.add(item);
+        }
+        return items;
+    }
+
+    private List<VppDevice> listMatchingDevices(String customerName, String siteName, String deviceName) {
+        Integer tenantId = SecurityUtils.getTenantId();
+        Set<Long> siteIds = resolveSiteIds(tenantId, customerName, siteName);
+        if (siteIds != null && siteIds.isEmpty()) {
+            return Collections.emptyList();
+        }
+        LambdaQueryWrapper<VppDevice> wrapper = new LambdaQueryWrapper<VppDevice>()
+                .eq(VppDevice::getDeleteFlag, VppAuditHelper.NOT_DELETED)
+                .eq(tenantId != null, VppDevice::getTenantId, tenantId)
+                .in(siteIds != null, VppDevice::getSiteId, siteIds)
+                .orderByDesc(VppDevice::getCreateTime);
+        if (StringUtils.hasText(deviceName)) {
+            wrapper.like(VppDevice::getDeviceName, deviceName.trim());
+        }
+        return deviceMapper.selectList(wrapper);
+    }
+
+    /**
+     * @return null 表示不按站点过滤;empty 表示无匹配站点
+     */
+    private Set<Long> resolveSiteIds(Integer tenantId, String customerName, String siteName) {
+        boolean filterCustomer = StringUtils.hasText(customerName);
+        boolean filterSite = StringUtils.hasText(siteName);
+        if (!filterCustomer && !filterSite) {
+            return null;
+        }
+        Set<Long> customerIds = null;
+        if (filterCustomer) {
+            customerIds = customerMapper.selectList(new LambdaQueryWrapper<VppCustomer>()
+                            .select(VppCustomer::getId)
+                            .eq(VppCustomer::getDeleteFlag, VppAuditHelper.NOT_DELETED)
+                            .eq(tenantId != null, VppCustomer::getTenantId, tenantId)
+                            .like(VppCustomer::getCustomerName, customerName.trim()))
+                    .stream()
+                    .map(VppCustomer::getId)
+                    .collect(Collectors.toSet());
+            if (customerIds.isEmpty()) {
+                return Collections.emptySet();
+            }
+        }
+        LambdaQueryWrapper<VppSite> siteWrapper = new LambdaQueryWrapper<VppSite>()
+                .select(VppSite::getId)
+                .eq(VppSite::getDeleteFlag, VppAuditHelper.NOT_DELETED)
+                .eq(tenantId != null, VppSite::getTenantId, tenantId)
+                .in(customerIds != null, VppSite::getCustomerId, customerIds);
+        if (filterSite) {
+            siteWrapper.like(VppSite::getSiteName, siteName.trim());
+        }
+        List<VppSite> sites = siteMapper.selectList(siteWrapper);
+        return sites.stream().map(VppSite::getId).collect(Collectors.toSet());
+    }
+
+    private Map<Long, VppSite> loadSiteMap(List<VppDevice> devices) {
+        Set<Long> siteIds = devices.stream()
+                .map(VppDevice::getSiteId)
+                .filter(Objects::nonNull)
+                .collect(Collectors.toSet());
+        if (siteIds.isEmpty()) {
+            return Collections.emptyMap();
+        }
+        return siteMapper.selectBatchIds(siteIds).stream()
+                .filter(site -> !VppAuditHelper.isDeleted(site.getDeleteFlag()))
+                .collect(Collectors.toMap(VppSite::getId, site -> site, (a, b) -> a));
+    }
+
+    private Map<Long, VppCustomer> loadCustomerMap(java.util.Collection<VppSite> sites) {
+        Set<Long> customerIds = sites.stream()
+                .map(VppSite::getCustomerId)
+                .filter(Objects::nonNull)
+                .collect(Collectors.toSet());
+        if (customerIds.isEmpty()) {
+            return Collections.emptyMap();
+        }
+        return customerMapper.selectBatchIds(customerIds).stream()
+                .filter(customer -> !VppAuditHelper.isDeleted(customer.getDeleteFlag()))
+                .collect(Collectors.toMap(VppCustomer::getId, customer -> customer, (a, b) -> a));
+    }
+
+    private Map<String, BigDecimal> loadRegulatedEnergy(List<VppDevice> devices,
+                                                        LocalDateTime startTime, LocalDateTime endTime) {
+        List<String> uuids = devices.stream()
+                .map(VppDevice::getDeviceUuid)
+                .map(this::trimUuid)
+                .filter(StringUtils::hasText)
+                .distinct()
+                .collect(Collectors.toList());
+        if (uuids.isEmpty()) {
+            return Collections.emptyMap();
+        }
+        Map<String, Map<String, TreeMap<LocalDateTime, BigDecimal>>> history =
+                tsdbQueryService.queryDeviceMetricHistory(uuids, startTime, endTime, ENERGY_METRICS);
+        Map<String, BigDecimal> result = new HashMap<>();
+        LocalDateTime endExclusive = endTime.plusSeconds(1);
+        for (String uuid : uuids) {
+            Map<String, TreeMap<LocalDateTime, BigDecimal>> metricMap = history.get(uuid);
+            TreeMap<LocalDateTime, BigDecimal> series = pickMetricSeries(metricMap, VppTsdbConstants.METRIC_EPP);
+            result.put(uuid, nz(VppEnergyUsageHelper.calcUsage(series, startTime, endExclusive)));
+        }
+        return result;
+    }
+
+    /**
+     * 按站点邀约实际响应量 × 事件时长得到电量,再按设备额定功率分摊。
+     */
+    private Map<Long, PeakValley> loadPeakValleyByDevice(List<VppDevice> filteredDevices,
+                                                         LocalDateTime startTime, LocalDateTime endTime) {
+        Set<Long> siteIds = filteredDevices.stream()
+                .map(VppDevice::getSiteId)
+                .filter(Objects::nonNull)
+                .collect(Collectors.toSet());
+        if (siteIds.isEmpty()) {
+            return Collections.emptyMap();
+        }
+        Integer tenantId = SecurityUtils.getTenantId();
+        List<VppDrEvent> events = eventMapper.selectList(new LambdaQueryWrapper<VppDrEvent>()
+                .eq(VppDrEvent::getDeleteFlag, VppAuditHelper.NOT_DELETED)
+                .eq(tenantId != null, VppDrEvent::getTenantId, tenantId)
+                .ne(VppDrEvent::getEventStatus, VppDrEventStatus.CANCELLED)
+                .and(w -> w.isNull(VppDrEvent::getStartTime).or().le(VppDrEvent::getStartTime, endTime))
+                .and(w -> w.isNull(VppDrEvent::getEndTime).or().ge(VppDrEvent::getEndTime, startTime)));
+        if (events.isEmpty()) {
+            return Collections.emptyMap();
+        }
+        Map<Long, VppDrEvent> eventMap = events.stream()
+                .collect(Collectors.toMap(VppDrEvent::getId, e -> e, (a, b) -> a));
+        Set<Long> eventIds = eventMap.keySet();
+        List<VppDrInvitation> invitations = invitationMapper.selectList(new LambdaQueryWrapper<VppDrInvitation>()
+                .in(VppDrInvitation::getDrEventId, eventIds)
+                .in(VppDrInvitation::getSiteId, siteIds)
+                .eq(VppDrInvitation::getDeleteFlag, VppAuditHelper.NOT_DELETED)
+                .eq(tenantId != null, VppDrInvitation::getTenantId, tenantId)
+                .and(w -> w.isNull(VppDrInvitation::getResponseStatus)
+                        .or()
+                        .ne(VppDrInvitation::getResponseStatus, VppDrEventStatus.CANCELLED)));
+        if (invitations.isEmpty()) {
+            return Collections.emptyMap();
+        }
+        Map<Long, VppDrEvaluation> evaluationMap = evaluationMapper.selectList(
+                        new LambdaQueryWrapper<VppDrEvaluation>()
+                                .in(VppDrEvaluation::getEventId, eventIds)
+                                .eq(VppDrEvaluation::getDeleteFlag, VppAuditHelper.NOT_DELETED))
+                .stream()
+                .collect(Collectors.toMap(VppDrEvaluation::getEventId, e -> e, (a, b) -> a));
+
+        List<VppDevice> siteDevices = deviceMapper.selectList(new LambdaQueryWrapper<VppDevice>()
+                .eq(VppDevice::getDeleteFlag, VppAuditHelper.NOT_DELETED)
+                .eq(tenantId != null, VppDevice::getTenantId, tenantId)
+                .in(VppDevice::getSiteId, siteIds));
+        Map<Long, List<VppDevice>> devicesBySite = siteDevices.stream()
+                .filter(d -> d.getSiteId() != null)
+                .collect(Collectors.groupingBy(VppDevice::getSiteId));
+
+        Map<Long, PeakValley> acc = new HashMap<>();
+        for (VppDrInvitation invitation : invitations) {
+            VppDrEvent event = eventMap.get(invitation.getDrEventId());
+            if (event == null || !overlaps(event, invitation, startTime, endTime)) {
+                continue;
+            }
+            BigDecimal energy = resolveInvitationEnergyKwh(invitation, event, evaluationMap.get(event.getId()));
+            if (energy.compareTo(BigDecimal.ZERO) <= 0) {
+                continue;
+            }
+            List<VppDevice> targets = devicesBySite.getOrDefault(invitation.getSiteId(), Collections.emptyList());
+            if (targets.isEmpty()) {
+                continue;
+            }
+            int eventType = resolveEventType(event, invitation);
+            Map<Long, BigDecimal> shares = allocateByRatedPower(targets, energy);
+            for (Map.Entry<Long, BigDecimal> entry : shares.entrySet()) {
+                PeakValley bucket = acc.computeIfAbsent(entry.getKey(), key -> new PeakValley());
+                if (eventType == VppDrEventType.VALLEY) {
+                    bucket.valley = bucket.valley.add(entry.getValue());
+                } else {
+                    bucket.peak = bucket.peak.add(entry.getValue());
+                }
+            }
+        }
+        for (PeakValley value : acc.values()) {
+            value.peak = value.peak.setScale(SCALE, RoundingMode.HALF_UP);
+            value.valley = value.valley.setScale(SCALE, RoundingMode.HALF_UP);
+        }
+        return acc;
+    }
+
+    private Map<Long, BigDecimal> allocateByRatedPower(List<VppDevice> devices, BigDecimal energy) {
+        Map<Long, BigDecimal> weights = new LinkedHashMap<>();
+        BigDecimal total = BigDecimal.ZERO;
+        for (VppDevice device : devices) {
+            BigDecimal weight = device.getRatedPowerKw() != null
+                    && device.getRatedPowerKw().compareTo(BigDecimal.ZERO) > 0
+                    ? device.getRatedPowerKw() : BigDecimal.ONE;
+            weights.put(device.getId(), weight);
+            total = total.add(weight);
+        }
+        Map<Long, BigDecimal> shares = new HashMap<>();
+        if (total.compareTo(BigDecimal.ZERO) <= 0) {
+            return shares;
+        }
+        for (Map.Entry<Long, BigDecimal> entry : weights.entrySet()) {
+            shares.put(entry.getKey(),
+                    energy.multiply(entry.getValue()).divide(total, 8, RoundingMode.HALF_UP));
+        }
+        return shares;
+    }
+
+    private BigDecimal resolveInvitationEnergyKwh(VppDrInvitation invitation, VppDrEvent event,
+                                                  VppDrEvaluation evaluation) {
+        BigDecimal kw = invitation.getActualResponseCapacityKw() != null
+                ? nz(invitation.getActualResponseCapacityKw())
+                : nz(invitation.getEstimatedResponseCapacityKw());
+        BigDecimal hours = resolveDurationHour(event, invitation, evaluation);
+        if (kw.compareTo(BigDecimal.ZERO) <= 0 || hours.compareTo(BigDecimal.ZERO) <= 0) {
+            return BigDecimal.ZERO;
+        }
+        return kw.multiply(hours);
+    }
+
+    private BigDecimal resolveDurationHour(VppDrEvent event, VppDrInvitation invitation,
+                                           VppDrEvaluation evaluation) {
+        if (evaluation != null && evaluation.getResponseDurationMin() != null
+                && evaluation.getResponseDurationMin() > 0) {
+            return BigDecimal.valueOf(evaluation.getResponseDurationMin())
+                    .divide(BigDecimal.valueOf(60), SCALE, RoundingMode.HALF_UP);
+        }
+        LocalDateTime start = event.getStartTime();
+        LocalDateTime end = event.getEndTime();
+        if (start == null && invitation.getExecuteStartDate() != null) {
+            start = invitation.getExecuteStartDate().atStartOfDay();
+        }
+        if (end == null && invitation.getExecuteEndDate() != null) {
+            end = invitation.getExecuteEndDate().atTime(23, 59, 59);
+        }
+        if (start != null && end != null && end.isAfter(start)) {
+            long minutes = ChronoUnit.MINUTES.between(start, end);
+            if (minutes > 0) {
+                return BigDecimal.valueOf(minutes)
+                        .divide(BigDecimal.valueOf(60), SCALE, RoundingMode.HALF_UP);
+            }
+        }
+        return BigDecimal.ZERO;
+    }
+
+    private boolean overlaps(VppDrEvent event, VppDrInvitation invitation,
+                             LocalDateTime queryStart, LocalDateTime queryEnd) {
+        LocalDateTime start = event.getStartTime();
+        LocalDateTime end = event.getEndTime();
+        if (start == null && invitation.getExecuteStartDate() != null) {
+            start = invitation.getExecuteStartDate().atStartOfDay();
+        }
+        if (end == null && invitation.getExecuteEndDate() != null) {
+            end = invitation.getExecuteEndDate().atTime(23, 59, 59);
+        }
+        if (start == null || end == null) {
+            return true;
+        }
+        return !start.isAfter(queryEnd) && !end.isBefore(queryStart);
+    }
+
+    private int resolveEventType(VppDrEvent event, VppDrInvitation invitation) {
+        if (event.getEventType() != null) {
+            return event.getEventType();
+        }
+        if (invitation.getTransactionType() != null) {
+            return invitation.getTransactionType();
+        }
+        return VppDrEventType.PEAK;
+    }
+
+    private EnergyStatSummaryVO buildSummary(List<EnergyStatItemVO> items) {
+        EnergyStatSummaryVO summary = new EnergyStatSummaryVO();
+        summary.setDeviceCount((long) items.size());
+        summary.setSiteCount(items.stream().map(EnergyStatItemVO::getSiteId).filter(Objects::nonNull).distinct().count());
+        BigDecimal regulated = BigDecimal.ZERO;
+        BigDecimal peak = BigDecimal.ZERO;
+        BigDecimal valley = BigDecimal.ZERO;
+        for (EnergyStatItemVO item : items) {
+            regulated = regulated.add(nz(item.getRegulatedEnergyKwh()));
+            peak = peak.add(nz(item.getPeakShaveKwh()));
+            valley = valley.add(nz(item.getValleyFillKwh()));
+        }
+        summary.setRegulatedEnergyKwh(regulated.setScale(SCALE, RoundingMode.HALF_UP));
+        summary.setPeakShaveKwh(peak.setScale(SCALE, RoundingMode.HALF_UP));
+        summary.setValleyFillKwh(valley.setScale(SCALE, RoundingMode.HALF_UP));
+        return summary;
+    }
+
+    private CommonPage<EnergyStatItemVO> paginate(List<EnergyStatItemVO> items, int current, int size) {
+        int from = Math.min((current - 1) * size, items.size());
+        int to = Math.min(from + size, items.size());
+        return new CommonPage<>(new ArrayList<>(items.subList(from, to)), (long) items.size(), current, size);
+    }
+
+    // ---------- 电表详情 ----------
+
+    private VppDevice requireDevice(Long deviceId) {
+        if (deviceId == null) {
+            throw new BusinessException("设备ID不能为空");
+        }
+        VppDevice device = deviceMapper.selectById(deviceId);
+        if (device == null || VppAuditHelper.isDeleted(device.getDeleteFlag())) {
+            throw new BusinessException("设备不存在");
+        }
+        return device;
+    }
+
+    private MeterDataVO loadMeterData(VppDevice device, LocalDateTime startTime, LocalDateTime endTime) {
+        MeterDataVO vo = new MeterDataVO();
+        vo.setDeviceId(device.getId());
+        vo.setDeviceUuid(device.getDeviceUuid());
+        vo.setDeviceName(device.getDeviceName());
+        vo.setSiteId(device.getSiteId());
+        fillMeterHeader(vo, device);
+
+        String uuid = trimUuid(device.getDeviceUuid());
+        if (!StringUtils.hasText(uuid)) {
+            throw new BusinessException("设备未绑定物联网 UUID,无法查询电表数据");
+        }
+
+        DmpDevice dmpDevice = dmpDeviceMapper.selectOne(new LambdaQueryWrapper<DmpDevice>()
+                .eq(DmpDevice::getDeviceUuid, uuid)
+                .eq(DmpDevice::getDeleteFlag, VppAuditHelper.NOT_DELETED)
+                .last("LIMIT 1"));
+        if (dmpDevice == null || dmpDevice.getProductId() == null) {
+            throw new BusinessException("物联网设备不存在或未关联产品,无法查询电表数据");
+        }
+
+        List<DmpProductAttribute> attributes = dmpProductAttributeMapper.selectList(
+                new LambdaQueryWrapper<DmpProductAttribute>()
+                        .eq(DmpProductAttribute::getProductId, dmpDevice.getProductId())
+                        .eq(DmpProductAttribute::getDeleteFlag, VppAuditHelper.NOT_DELETED)
+                        .eq(dmpDevice.getTenantId() != null, DmpProductAttribute::getTenantId, dmpDevice.getTenantId())
+                        .isNotNull(DmpProductAttribute::getAttributeCode)
+                        .ne(DmpProductAttribute::getAttributeCode, "")
+                        .orderByAsc(DmpProductAttribute::getId));
+        attributes = attributes.stream()
+                .filter(attr -> StringUtils.hasText(attr.getAttributeCode())
+                        && !"ts".equalsIgnoreCase(attr.getAttributeCode().trim()))
+                .collect(Collectors.toList());
+        attributes = sortAttributes(attributes);
+
+        List<MeterDataVO.MeterColumnVO> columns = new ArrayList<>();
+        List<String> metrics = new ArrayList<>();
+        for (DmpProductAttribute attribute : attributes) {
+            String code = attribute.getAttributeCode().trim();
+            metrics.add(code);
+            MeterDataVO.MeterColumnVO column = new MeterDataVO.MeterColumnVO();
+            column.setAttributeCode(code);
+            column.setAttributeName(attribute.getAttributeName());
+            column.setAttributeUnit(attribute.getAttributeUnit());
+            column.setHeader(buildColumnHeader(code, attribute.getAttributeName(), attribute.getAttributeUnit()));
+            columns.add(column);
+        }
+        vo.setColumns(columns);
+
+        if (metrics.isEmpty()) {
+            log.warn("产品无可用属性, productId={}, deviceUuid={}", dmpDevice.getProductId(), uuid);
+            return vo;
+        }
+
+        Map<String, Map<String, TreeMap<LocalDateTime, BigDecimal>>> history =
+                tsdbQueryService.queryDeviceMetricHistory(
+                        Collections.singletonList(uuid), startTime, endTime, metrics);
+        Map<String, TreeMap<LocalDateTime, BigDecimal>> metricMap = history.getOrDefault(uuid, Collections.emptyMap());
+
+        String powerMetric = resolvePowerMetric(columns, metricMap);
+        vo.setPowerMetric(powerMetric);
+        if (powerMetric != null) {
+            for (MeterDataVO.MeterColumnVO column : columns) {
+                if (powerMetric.equalsIgnoreCase(column.getAttributeCode())) {
+                    vo.setPowerMetricName(column.getAttributeName());
+                    break;
+                }
+            }
+            TreeMap<LocalDateTime, BigDecimal> powerSeries = pickMetricSeries(metricMap, powerMetric);
+            if (powerSeries != null) {
+                List<MeterDataVO.MeterCurvePointVO> curve = new ArrayList<>();
+                for (Map.Entry<LocalDateTime, BigDecimal> point : powerSeries.entrySet()) {
+                    MeterDataVO.MeterCurvePointVO curvePoint = new MeterDataVO.MeterCurvePointVO();
+                    curvePoint.setTime(point.getKey().format(TIME_FMT));
+                    curvePoint.setValue(point.getValue());
+                    curve.add(curvePoint);
+                }
+                vo.setCurve(curve);
+            }
+        }
+
+        vo.setRecords(buildMeterRecords(columns, metricMap));
+        return vo;
+    }
+
+    private void fillMeterHeader(MeterDataVO vo, VppDevice device) {
+        if (device.getSiteId() == null) {
+            return;
+        }
+        VppSite site = siteMapper.selectById(device.getSiteId());
+        if (site == null || VppAuditHelper.isDeleted(site.getDeleteFlag())) {
+            return;
+        }
+        vo.setSiteName(site.getSiteName());
+        if (site.getCustomerId() == null) {
+            return;
+        }
+        vo.setCustomerId(site.getCustomerId());
+        VppCustomer customer = customerMapper.selectById(site.getCustomerId());
+        if (customer != null && !VppAuditHelper.isDeleted(customer.getDeleteFlag())) {
+            vo.setCustomerName(customer.getCustomerName());
+        }
+    }
+
+    private List<MeterDataVO.MeterRecordVO> buildMeterRecords(List<MeterDataVO.MeterColumnVO> columns,
+                                                              Map<String, TreeMap<LocalDateTime, BigDecimal>> metricMap) {
+        TreeSet<LocalDateTime> timestamps = new TreeSet<>();
+        Map<String, TreeMap<LocalDateTime, BigDecimal>> seriesByCode = new HashMap<>();
+        for (MeterDataVO.MeterColumnVO column : columns) {
+            TreeMap<LocalDateTime, BigDecimal> series = pickMetricSeries(metricMap, column.getAttributeCode());
+            if (series == null) {
+                series = new TreeMap<>();
+            }
+            seriesByCode.put(column.getAttributeCode(), series);
+            timestamps.addAll(series.keySet());
+        }
+        List<MeterDataVO.MeterRecordVO> records = new ArrayList<>();
+        for (LocalDateTime ts : timestamps) {
+            MeterDataVO.MeterRecordVO record = new MeterDataVO.MeterRecordVO();
+            record.setCollectTime(ts.format(TIME_FMT));
+            Map<String, BigDecimal> values = new LinkedHashMap<>();
+            for (MeterDataVO.MeterColumnVO column : columns) {
+                values.put(column.getAttributeCode(), seriesByCode.get(column.getAttributeCode()).get(ts));
+            }
+            record.setValues(values);
+            records.add(record);
+        }
+        return records;
+    }
+
+    private List<DmpProductAttribute> sortAttributes(List<DmpProductAttribute> attributes) {
+        Map<String, Integer> order = new HashMap<>();
+        int idx = 0;
+        for (String code : POWER_METRIC_PRIORITY) {
+            order.put(code.toLowerCase(), idx++);
+        }
+        for (String code : Arrays.asList("epp", "ep", "energy", "eday", "ua", "u", "voltage", "ia", "i", "current")) {
+            order.putIfAbsent(code, idx++);
+        }
+        List<DmpProductAttribute> sorted = new ArrayList<>(attributes);
+        sorted.sort((a, b) -> {
+            int oa = order.getOrDefault(a.getAttributeCode().trim().toLowerCase(), Integer.MAX_VALUE);
+            int ob = order.getOrDefault(b.getAttributeCode().trim().toLowerCase(), Integer.MAX_VALUE);
+            if (oa != ob) {
+                return Integer.compare(oa, ob);
+            }
+            Integer ida = a.getId() == null ? Integer.MAX_VALUE : a.getId();
+            Integer idb = b.getId() == null ? Integer.MAX_VALUE : b.getId();
+            return ida.compareTo(idb);
+        });
+        return sorted;
+    }
+
+    private String resolvePowerMetric(List<MeterDataVO.MeterColumnVO> columns,
+                                      Map<String, TreeMap<LocalDateTime, BigDecimal>> metricMap) {
+        for (String candidate : POWER_METRIC_PRIORITY) {
+            for (MeterDataVO.MeterColumnVO column : columns) {
+                if (candidate.equalsIgnoreCase(column.getAttributeCode())) {
+                    return column.getAttributeCode();
+                }
+            }
+        }
+        for (MeterDataVO.MeterColumnVO column : columns) {
+            String name = column.getAttributeName() == null ? "" : column.getAttributeName();
+            if (name.contains("有功功率")) {
+                return column.getAttributeCode();
+            }
+        }
+        if (!CollectionUtils.isEmpty(metricMap)) {
+            for (MeterDataVO.MeterColumnVO column : columns) {
+                TreeMap<LocalDateTime, BigDecimal> series = pickMetricSeries(metricMap, column.getAttributeCode());
+                if (series != null && !series.isEmpty()) {
+                    return column.getAttributeCode();
+                }
+            }
+        }
+        return columns.isEmpty() ? null : columns.get(0).getAttributeCode();
+    }
+
+    // ---------- Excel ----------
+
+    private void writeEnergyExcel(String fileName, List<EnergyStatItemVO> items, HttpServletResponse response) {
+        try (XSSFWorkbook workbook = new XSSFWorkbook()) {
+            XSSFSheet sheet = workbook.createSheet("能源统计");
+            XSSFCellStyle headerStyle = headerStyle(workbook);
+            String[] headers = {
+                    "序号", "客户名称", "站点名称", "设备名称",
+                    "调节用电量(kWh)", "削峰量(kWh)", "填谷量(kWh)"
+            };
+            XSSFRow headerRow = sheet.createRow(0);
+            for (int i = 0; i < headers.length; i++) {
+                XSSFCell cell = headerRow.createCell(i);
+                cell.setCellValue(headers[i]);
+                cell.setCellStyle(headerStyle);
+            }
+            int rowIdx = 1;
+            for (EnergyStatItemVO item : items) {
+                XSSFRow row = sheet.createRow(rowIdx++);
+                setCell(row, 0, rowIdx - 1);
+                setCell(row, 1, item.getCustomerName());
+                setCell(row, 2, item.getSiteName());
+                setCell(row, 3, item.getDeviceName());
+                setCell(row, 4, numberStr(item.getRegulatedEnergyKwh()));
+                setCell(row, 5, numberStr(item.getPeakShaveKwh()));
+                setCell(row, 6, numberStr(item.getValleyFillKwh()));
+            }
+            autosize(sheet, headers.length);
+            writeExcelResponse(fileName, workbook, response);
+        } catch (IOException e) {
+            throw new BusinessException("导出能源统计失败: " + e.getMessage());
+        }
+    }
+
+    private void writeMeterExcel(String fileName, MeterDataVO meterData, HttpServletResponse response) {
+        try (XSSFWorkbook workbook = new XSSFWorkbook()) {
+            XSSFSheet sheet = workbook.createSheet("电表数据");
+            XSSFCellStyle headerStyle = headerStyle(workbook);
+            List<MeterDataVO.MeterColumnVO> columns = meterData.getColumns() == null
+                    ? Collections.emptyList() : meterData.getColumns();
+
+            XSSFRow headerRow = sheet.createRow(0);
+            XSSFCell timeHeader = headerRow.createCell(0);
+            timeHeader.setCellValue("collectTime(采集时间)");
+            timeHeader.setCellStyle(headerStyle);
+            for (int i = 0; i < columns.size(); i++) {
+                XSSFCell cell = headerRow.createCell(i + 1);
+                cell.setCellValue(columns.get(i).getHeader());
+                cell.setCellStyle(headerStyle);
+            }
+
+            List<MeterDataVO.MeterRecordVO> records = meterData.getRecords() == null
+                    ? Collections.emptyList() : meterData.getRecords();
+            int rowIdx = 1;
+            for (MeterDataVO.MeterRecordVO record : records) {
+                XSSFRow row = sheet.createRow(rowIdx++);
+                setCell(row, 0, record.getCollectTime());
+                Map<String, BigDecimal> values = record.getValues() == null
+                        ? Collections.emptyMap() : record.getValues();
+                for (int i = 0; i < columns.size(); i++) {
+                    setCell(row, i + 1, numberStr(values.get(columns.get(i).getAttributeCode())));
+                }
+            }
+            autosize(sheet, columns.size() + 1);
+            writeExcelResponse(fileName, workbook, response);
+        } catch (IOException e) {
+            throw new BusinessException("导出电表数据失败: " + e.getMessage());
+        }
+    }
+
+    private XSSFCellStyle headerStyle(XSSFWorkbook workbook) {
+        XSSFCellStyle headerStyle = workbook.createCellStyle();
+        headerStyle.setFillForegroundColor(IndexedColors.GREY_25_PERCENT.getIndex());
+        headerStyle.setFillPattern(FillPatternType.SOLID_FOREGROUND);
+        XSSFFont headerFont = workbook.createFont();
+        headerFont.setBold(true);
+        headerStyle.setFont(headerFont);
+        return headerStyle;
+    }
+
+    private void autosize(XSSFSheet sheet, int columnCount) {
+        for (int i = 0; i < columnCount; i++) {
+            sheet.autoSizeColumn(i);
+            int width = sheet.getColumnWidth(i);
+            if (width < 4000) {
+                sheet.setColumnWidth(i, 4000);
+            } else if (width > 15000) {
+                sheet.setColumnWidth(i, 15000);
+            }
+        }
+    }
+
+    private void writeExcelResponse(String fileName, XSSFWorkbook workbook, HttpServletResponse response)
+            throws IOException {
+        String encodedFileName = URLEncoder.encode(fileName + ".xlsx", StandardCharsets.UTF_8.name())
+                .replace("+", "%20");
+        response.setContentType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet");
+        response.setCharacterEncoding(StandardCharsets.UTF_8.name());
+        response.setHeader("Content-Disposition", "attachment; filename=" + encodedFileName
+                + "; filename*=UTF-8''" + encodedFileName);
+        workbook.write(response.getOutputStream());
+        response.getOutputStream().flush();
+    }
+
+    // ---------- helpers ----------
+
+    private LocalDateTime[] resolveTimeRange(LocalDateTime startTime, LocalDateTime endTime) {
+        LocalDateTime start = startTime != null ? startTime : LocalDate.now().atStartOfDay();
+        LocalDateTime end = endTime != null ? endTime : LocalDate.now().atTime(23, 59, 59);
+        if (start.isAfter(end)) {
+            throw new BusinessException("开始时间不能晚于结束时间");
+        }
+        return new LocalDateTime[]{start, end};
+    }
+
+    private TreeMap<LocalDateTime, BigDecimal> pickMetricSeries(
+            Map<String, TreeMap<LocalDateTime, BigDecimal>> metricMap, String metric) {
+        if (CollectionUtils.isEmpty(metricMap) || !StringUtils.hasText(metric)) {
+            return null;
+        }
+        TreeMap<LocalDateTime, BigDecimal> series = metricMap.get(metric);
+        if (series != null) {
+            return series;
+        }
+        for (Map.Entry<String, TreeMap<LocalDateTime, BigDecimal>> entry : metricMap.entrySet()) {
+            if (metric.equalsIgnoreCase(entry.getKey())) {
+                return entry.getValue();
+            }
+        }
+        return null;
+    }
+
+    private String buildColumnHeader(String code, String name, String unit) {
+        StringBuilder header = new StringBuilder(code);
+        header.append("(");
+        if (StringUtils.hasText(name)) {
+            header.append(name.trim());
+        }
+        if (StringUtils.hasText(unit)) {
+            if (StringUtils.hasText(name)) {
+                header.append("/");
+            }
+            header.append(unit.trim());
+        }
+        if (!StringUtils.hasText(name) && !StringUtils.hasText(unit)) {
+            header.append("属性");
+        }
+        header.append(")");
+        return header.toString();
+    }
+
+    private String trimUuid(String uuid) {
+        return StringUtils.hasText(uuid) ? uuid.trim() : null;
+    }
+
+    private static void setCell(XSSFRow row, int col, Object value) {
+        row.createCell(col).setCellValue(value == null ? "" : String.valueOf(value));
+    }
+
+    private static String numberStr(Object value) {
+        return value == null ? "" : value.toString();
+    }
+
+    private static BigDecimal nz(BigDecimal value) {
+        return value == null ? BigDecimal.ZERO.setScale(SCALE, RoundingMode.HALF_UP) : value;
+    }
+
+    private static List<String> buildPowerMetricPriority() {
+        List<String> codes = new ArrayList<>();
+        codes.add(VppTsdbConstants.METRIC_P);
+        codes.addAll(VppTsdbConstants.ACTIVE_POWER_METRICS);
+        return codes;
+    }
+
+    private static final class PeakValley {
+        private BigDecimal peak = BigDecimal.ZERO;
+        private BigDecimal valley = BigDecimal.ZERO;
+    }
+}

+ 29 - 0
service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/service/vo/EnergyStatItemVO.java

@@ -0,0 +1,29 @@
+package com.usky.vpp.service.vo;
+
+import lombok.Data;
+
+import java.math.BigDecimal;
+
+/**
+ * 能源统计列表项(设备维度)
+ */
+@Data
+public class EnergyStatItemVO {
+
+    private Long deviceId;
+    private String deviceUuid;
+    private String deviceName;
+    private Long siteId;
+    private String siteName;
+    private Long customerId;
+    private String customerName;
+
+    /** 调节用电量 kWh */
+    private BigDecimal regulatedEnergyKwh = BigDecimal.ZERO;
+
+    /** 削峰量 kWh */
+    private BigDecimal peakShaveKwh = BigDecimal.ZERO;
+
+    /** 填谷量 kWh */
+    private BigDecimal valleyFillKwh = BigDecimal.ZERO;
+}

+ 15 - 0
service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/service/vo/EnergyStatPageVO.java

@@ -0,0 +1,15 @@
+package com.usky.vpp.service.vo;
+
+import com.usky.common.core.bean.CommonPage;
+import lombok.Data;
+
+/**
+ * 能源统计分页结果(含 KPI 汇总)
+ */
+@Data
+public class EnergyStatPageVO {
+
+    private EnergyStatSummaryVO summary = new EnergyStatSummaryVO();
+
+    private CommonPage<EnergyStatItemVO> page;
+}

+ 27 - 0
service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/service/vo/EnergyStatSummaryVO.java

@@ -0,0 +1,27 @@
+package com.usky.vpp.service.vo;
+
+import lombok.Data;
+
+import java.math.BigDecimal;
+
+/**
+ * 能源统计汇总 KPI
+ */
+@Data
+public class EnergyStatSummaryVO {
+
+    /** 站点数 */
+    private Long siteCount = 0L;
+
+    /** 设备数 */
+    private Long deviceCount = 0L;
+
+    /** 调节用电量 kWh */
+    private BigDecimal regulatedEnergyKwh = BigDecimal.ZERO;
+
+    /** 削峰量 kWh */
+    private BigDecimal peakShaveKwh = BigDecimal.ZERO;
+
+    /** 填谷量 kWh */
+    private BigDecimal valleyFillKwh = BigDecimal.ZERO;
+}

+ 65 - 0
service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/service/vo/MeterDataVO.java

@@ -0,0 +1,65 @@
+package com.usky.vpp.service.vo;
+
+import lombok.Data;
+
+import java.math.BigDecimal;
+import java.util.ArrayList;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * 电表数据详情(含功率曲线与时序明细)
+ */
+@Data
+public class MeterDataVO {
+
+    private Long deviceId;
+    private String deviceUuid;
+    private String deviceName;
+    private Long siteId;
+    private String siteName;
+    private Long customerId;
+    private String customerName;
+
+    /** 曲线使用的属性标识 */
+    private String powerMetric;
+    /** 曲线使用的属性名称 */
+    private String powerMetricName;
+
+    /** 列定义(来自 dmp_product_attribute) */
+    private List<MeterColumnVO> columns = new ArrayList<>();
+
+    /** 电表功率曲线 */
+    private List<MeterCurvePointVO> curve = new ArrayList<>();
+
+    /** 时序明细 */
+    private List<MeterRecordVO> records = new ArrayList<>();
+
+    @Data
+    public static class MeterColumnVO {
+        /** 英文属性标识 */
+        private String attributeCode;
+        /** 中文属性名称 */
+        private String attributeName;
+        /** 单位 */
+        private String attributeUnit;
+        /** 导出/展示标题,如 p(有功功率/kW) */
+        private String header;
+    }
+
+    @Data
+    public static class MeterCurvePointVO {
+        /** yyyy-MM-dd HH:mm:ss */
+        private String time;
+        private BigDecimal value;
+    }
+
+    @Data
+    public static class MeterRecordVO {
+        /** 采集时间 yyyy-MM-dd HH:mm:ss */
+        private String collectTime;
+        /** attributeCode -> 数值 */
+        private Map<String, BigDecimal> values = new LinkedHashMap<>();
+    }
+}