Quellcode durchsuchen

Merge branch 'fyc-ems' of uskycloud/usky-modules into usky-ems

fuyuchuan vor 1 Tag
Ursprung
Commit
f29842eb33
18 geänderte Dateien mit 2618 neuen und 495 gelöschten Zeilen
  1. 9 12
      service-ems/service-ems-biz/src/main/java/com/usky/ems/controller/web/EmsAnalysisController.java
  2. 58 0
      service-ems/service-ems-biz/src/main/java/com/usky/ems/controller/web/EmsReportController.java
  3. 4 4
      service-ems/service-ems-biz/src/main/java/com/usky/ems/domain/EmsEnergyItemCode.java
  4. 2 0
      service-ems/service-ems-biz/src/main/java/com/usky/ems/service/EmsAnalysisService.java
  5. 34 0
      service-ems/service-ems-biz/src/main/java/com/usky/ems/service/EmsReportService.java
  6. 254 471
      service-ems/service-ems-biz/src/main/java/com/usky/ems/service/impl/EmsAnalysisServiceImpl.java
  7. 1782 7
      service-ems/service-ems-biz/src/main/java/com/usky/ems/service/impl/EmsReportServiceImpl.java
  8. 20 0
      service-ems/service-ems-biz/src/main/java/com/usky/ems/service/vo/EmsAveragePointVO.java
  9. 48 0
      service-ems/service-ems-biz/src/main/java/com/usky/ems/service/vo/EmsAverageRequest.java
  10. 23 0
      service-ems/service-ems-biz/src/main/java/com/usky/ems/service/vo/EmsAverageResponseVO.java
  11. 17 0
      service-ems/service-ems-biz/src/main/java/com/usky/ems/service/vo/EmsAverageSpaceItemVO.java
  12. 4 1
      service-ems/service-ems-biz/src/main/java/com/usky/ems/service/vo/EmsCompareResponse.java
  13. 93 0
      service-ems/service-ems-biz/src/main/java/com/usky/ems/service/vo/EnergyReportRequest.java
  14. 81 0
      service-ems/service-ems-biz/src/main/java/com/usky/ems/service/vo/EnergyReportResponse.java
  15. 50 0
      service-ems/service-ems-biz/src/main/java/com/usky/ems/service/vo/ItemReportRequest.java
  16. 46 0
      service-ems/service-ems-biz/src/main/java/com/usky/ems/service/vo/ItemReportResponse.java
  17. 63 0
      service-ems/service-ems-biz/src/main/java/com/usky/ems/service/vo/SpaceReportRequest.java
  18. 30 0
      service-ems/service-ems-biz/src/main/java/com/usky/ems/service/vo/SpaceReportResponse.java

+ 9 - 12
service-ems/service-ems-biz/src/main/java/com/usky/ems/controller/web/EmsAnalysisController.java

@@ -19,14 +19,14 @@ public class EmsAnalysisController {
 
     @Autowired
     private EmsAnalysisService emsAnalysisService;
-    
+
     /**
      * 获取当前日期的默认值
      */
     private String getCurrentDefaultTimeDimension() {
         return "D"; // 按日统计
     }
-    
+
     /**
      * 获取当前日期的默认值(根据时间维度返回对应格式)
      */
@@ -80,16 +80,13 @@ public class EmsAnalysisController {
         return ApiResult.success(emsAnalysisService.getTrendCategory(projectId, timeDimension, timeValue, energyTypeId));
     }
 
-    @GetMapping("/region")
-    public ApiResult<EmsRegionAnalysisResponse> getRegionAnalysis(
-            @RequestParam(required = false) Long projectId,
-            @RequestParam(required = false) String regionIds,
-            @RequestParam(required = false) String timeDimension,
-            @RequestParam(required = false) String timeValue,
-            @RequestParam(required = false) Long energyTypeId) {
-        if (timeDimension == null) timeDimension = getCurrentDefaultTimeDimension();
-        if (timeValue == null) timeValue = getCurrentDefaultTimeValue(timeDimension);
-        return ApiResult.success(emsAnalysisService.getRegionAnalysis(projectId, regionIds, timeDimension, timeValue, energyTypeId));
+    /**
+     * 区域能耗时序分析
+     * 按分项编码与区域列表,查询各区域在时间轴上的能耗数据
+     */
+    @PostMapping("/region")
+    public ApiResult<EmsAverageResponseVO> getRegionAnalysis(@RequestBody EmsAverageRequest request) {
+        return ApiResult.success(emsAnalysisService.getRegionalAnalysis(request));
     }
 
     @PostMapping("/compare")

+ 58 - 0
service-ems/service-ems-biz/src/main/java/com/usky/ems/controller/web/EmsReportController.java

@@ -4,6 +4,7 @@ import com.usky.common.core.bean.ApiResult;
 import com.usky.ems.service.EmsReportService;
 import com.usky.ems.service.EmsModelService;
 import com.usky.ems.service.vo.*;
+import io.swagger.annotations.ApiOperation;
 import org.springframework.beans.factory.annotation.Autowired;
 import org.springframework.web.bind.annotation.*;
 
@@ -109,4 +110,61 @@ public class EmsReportController {
             HttpServletResponse response) {
         emsReportService.exportCollection(deviceIds, attributePointIds, timeDimension, timeValue, format, response);
     }
+
+    /**
+     * 1. 能源报表
+     * 支持按设备列表、功能点、时间类型(时/日/月/年)聚合数据
+     *
+     * @param request 能源报表请求参数
+     * @return 能源报表响应数据
+     */
+    @PostMapping("/energy")
+    @ApiOperation("能源报表")
+    public ApiResult<EnergyReportResponse> getEnergyReport(@RequestBody EnergyReportRequest request) {
+        return ApiResult.success(emsReportService.getEnergyReport(request));
+    }
+
+    /**
+     * 能源报表导出 Excel(请求体与 /report/energy 一致)
+     */
+    @PostMapping("/energy/export")
+    @ApiOperation("能源报表导出")
+    public void exportEnergyReport(@RequestBody EnergyReportRequest request, HttpServletResponse response) {
+        emsReportService.exportEnergyReport(request, response);
+    }
+
+    /**
+     * 2. 分项报表
+     * 支持按空间ID、分项编码、日期类型统计能耗
+     *
+     * @param request 分项报表请求参数
+     * @return 分项报表响应数据
+     */
+    @PostMapping("/item")
+    @ApiOperation("分项报表")
+    public ApiResult<ItemReportResponse> getItemReport(@RequestBody ItemReportRequest request) {
+        return ApiResult.success(emsReportService.getItemReport(request));
+    }
+
+    /**
+     * 3. 区域报表
+     * 支持按区域、分项、日期类型统计能耗
+     *
+     * @param request 区域报表请求参数
+     * @return 区域报表响应数据
+     */
+    @PostMapping("/space")
+    @ApiOperation("区域报表")
+    public ApiResult<SpaceReportResponse> getSpaceReport(@RequestBody SpaceReportRequest request) {
+        return ApiResult.success(emsReportService.getSpaceReport(request));
+    }
+
+    /**
+     * 区域报表导出 Excel(请求体与 /report/space 一致)
+     */
+    @PostMapping("/space/export")
+    @ApiOperation("区域报表导出")
+    public void exportSpaceReport(@RequestBody SpaceReportRequest request, HttpServletResponse response) {
+        emsReportService.exportSpaceReport(request, response);
+    }
 }

+ 4 - 4
service-ems/service-ems-biz/src/main/java/com/usky/ems/domain/EmsEnergyItemCode.java

@@ -34,16 +34,16 @@ public class EmsEnergyItemCode implements Serializable {
     private String identifier;
     @TableField("energy_type")
     private Integer energyType;
-
+    @TableField("updated_by")
+    private Long updatedBy;
+    @TableField("update_time")
+    private LocalDateTime updateTime;
     @TableField("created_by")
     private String createdBy;
 
     @TableField("created_time")
     private LocalDateTime createdTime;
 
-    @TableField("updated_by")
-    private String updatedBy;
-
     @TableField("updated_time")
     private LocalDateTime updatedTime;
 

+ 2 - 0
service-ems/service-ems-biz/src/main/java/com/usky/ems/service/EmsAnalysisService.java

@@ -18,4 +18,6 @@ public interface EmsAnalysisService {
     EmsCompareResponse getCompare(EmsCompareRequest request);
 
     EmsAverageResponse getAverage(Long projectId, String timeDimension, String timeValue, Long energyTypeId);
+
+    EmsAverageResponseVO getRegionalAnalysis(EmsAverageRequest request);
 }

+ 34 - 0
service-ems/service-ems-biz/src/main/java/com/usky/ems/service/EmsReportService.java

@@ -29,4 +29,38 @@ public interface EmsReportService {
     EmsEnergyStatisticsResponse getCollectionStatistics(EmsEnergyStatisticsRequest request);
 
     void exportCollection(String deviceIds, String attributePointIds, String timeDimension, String timeValue, String format, HttpServletResponse response);
+
+    /**
+     * 获取能源报表数据
+     *
+     * @param request 请求参数
+     * @return 能源报表响应
+     */
+    EnergyReportResponse getEnergyReport(EnergyReportRequest request);
+
+    /**
+     * 获取分项报表数据
+     *
+     * @param request 请求参数
+     * @return 分项报表响应
+     */
+    ItemReportResponse getItemReport(ItemReportRequest request);
+
+    /**
+     * 获取区域报表数据
+     *
+     * @param request 请求参数
+     * @return 区域报表响应
+     */
+    SpaceReportResponse getSpaceReport(SpaceReportRequest request);
+
+    /**
+     * 导出能源报表 Excel
+     */
+    void exportEnergyReport(EnergyReportRequest request, HttpServletResponse response);
+
+    /**
+     * 导出区域报表 Excel
+     */
+    void exportSpaceReport(SpaceReportRequest request, HttpServletResponse response);
 }

Datei-Diff unterdrückt, da er zu groß ist
+ 254 - 471
service-ems/service-ems-biz/src/main/java/com/usky/ems/service/impl/EmsAnalysisServiceImpl.java


+ 1782 - 7
service-ems/service-ems-biz/src/main/java/com/usky/ems/service/impl/EmsReportServiceImpl.java

@@ -1,15 +1,32 @@
 package com.usky.ems.service.impl;
 
 import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.baomidou.mybatisplus.core.toolkit.Wrappers;
+import com.usky.common.core.bean.ApiResult;
+import com.usky.common.core.exception.BusinessException;
 import com.usky.common.security.utils.SecurityUtils;
-import com.usky.ems.domain.DmpDevice;
-import com.usky.ems.domain.EmsDevice;
-import com.usky.ems.domain.EmsDeviceFunction;
-import com.usky.ems.mapper.DmpDeviceMapper;
-import com.usky.ems.mapper.EmsDeviceFunctionMapper;
-import com.usky.ems.mapper.EmsDeviceMapper;
+import com.usky.demo.RemoteTsdbProxyService;
+import com.usky.demo.domain.HistorysInnerRequestVO;
+import com.usky.demo.domain.HistorysInnerResultVO;
+import com.usky.demo.domain.MetricVO;
+import com.usky.ems.domain.*;
+import com.usky.ems.mapper.*;
 import com.usky.ems.service.EmsReportService;
 import com.usky.ems.service.vo.*;
+import lombok.extern.slf4j.Slf4j;
+import org.apache.poi.ss.usermodel.BorderStyle;
+import org.apache.poi.ss.usermodel.Cell;
+import org.apache.poi.ss.usermodel.CellStyle;
+import org.apache.poi.ss.usermodel.FillPatternType;
+import org.apache.poi.ss.usermodel.Font;
+import org.apache.poi.ss.usermodel.HorizontalAlignment;
+import org.apache.poi.ss.usermodel.IndexedColors;
+import org.apache.poi.ss.usermodel.Row;
+import org.apache.poi.ss.usermodel.Sheet;
+import org.apache.poi.ss.usermodel.VerticalAlignment;
+import org.apache.poi.ss.usermodel.Workbook;
+import org.apache.poi.ss.util.CellRangeAddress;
+import org.apache.poi.xssf.usermodel.XSSFWorkbook;
 import org.springframework.beans.factory.annotation.Autowired;
 import org.springframework.stereotype.Service;
 import org.springframework.util.StringUtils;
@@ -17,7 +34,15 @@ import org.springframework.util.StringUtils;
 import javax.servlet.http.HttpServletResponse;
 import java.io.IOException;
 import java.io.OutputStream;
+import java.io.UnsupportedEncodingException;
+import java.net.URLEncoder;
+import java.math.BigDecimal;
+import java.math.RoundingMode;
 import java.nio.charset.StandardCharsets;
+import java.time.LocalDate;
+import java.time.LocalDateTime;
+import java.time.LocalTime;
+import java.time.format.DateTimeFormatter;
 import java.util.*;
 import java.util.stream.Collectors;
 
@@ -25,14 +50,46 @@ import java.util.stream.Collectors;
  * 统计报表服务实现(设备列表基于 leo 设备与属性点位,统计与导出为占位/预留)
  */
 @Service
+@Slf4j
 public class EmsReportServiceImpl implements EmsReportService {
 
     private static final String[] ENERGY_TYPE_NAMES = {"", "电", "水", "气"};
 
+    private static final String ENERGY_NO_DATA = "-";
+    private static final String ENERGY_ZERO = "0.00";
+
+    private static final Set<String> CUMULATIVE_ENERGY_METRICS = Collections.unmodifiableSet(new HashSet<>(Arrays.asList(
+            "totalactiveenergyf",
+            "totalactiveenergyr",
+            "totalreactiveenergyf",
+            "totalreactiveenergyr"
+    )));
+
+    private enum MetricAggregateType {
+        /** 累计表计:时段内末次读数 - 首次读数 */
+        CUMULATIVE_DELTA,
+        /** 瞬时量:时段内算术平均 */
+        INSTANTANEOUS_AVG
+    }
+
+    private static final DateTimeFormatter DATE_TIME_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
+
     @Autowired
     private DmpDeviceMapper dmpDeviceMapper;
     @Autowired
     private EmsDeviceFunctionMapper emsDeviceFunctionMapper;
+    @Autowired
+    private RemoteTsdbProxyService remoteTsdbProxyService;
+    @Autowired
+    private BaseSpaceGatewayMapper baseSpaceGatewayMapper;
+    @Autowired
+    private EmsDeviceItemCodeMapper emsDeviceItemCodeMapper;
+    @Autowired
+    private EmsProductEnergyTypeMapper emsProductEnergyTypeMapper;
+    @Autowired
+    private BaseSpaceMapper baseSpaceMapper;
+    @Autowired
+    private EmsEnergyItemCodeMapper emsEnergyItemCodeMapper;
 
     private String energyTypeName(Long energyTypeId) {
         if (energyTypeId == null || energyTypeId < 1 || energyTypeId > 3) return "";
@@ -41,7 +98,7 @@ public class EmsReportServiceImpl implements EmsReportService {
 
     @Override
     public EmsReportDevicesResponse getEnergyDevices(Long energyTypeId, String keyword, Long projectId) {
-        LambdaQueryWrapper<DmpDevice> q = new LambdaQueryWrapper<DmpDevice>().eq(DmpDevice::getDeleteFlag,0).eq(DmpDevice::getTenantId, SecurityUtils.getTenantId());
+        LambdaQueryWrapper<DmpDevice> q = new LambdaQueryWrapper<DmpDevice>().eq(DmpDevice::getDeleteFlag, 0).eq(DmpDevice::getTenantId, SecurityUtils.getTenantId());
         if (StringUtils.hasText(keyword)) {
             q.and(w -> w.like(DmpDevice::getDeviceName, keyword));
         }
@@ -150,4 +207,1722 @@ public class EmsReportServiceImpl implements EmsReportService {
             throw new RuntimeException("导出失败", e);
         }
     }
+
+    @Override
+    public EnergyReportResponse getEnergyReport(EnergyReportRequest request) {
+        if (request == null || request.getDeviceList() == null || request.getDeviceList().isEmpty()) {
+            return createEmptyEnergyReport();
+        }
+
+        // 解析时间
+        LocalDateTime startTime = parseDateTime(request.getStartTime());
+        LocalDateTime endTime = parseDateTime(request.getEndTime());
+        if (startTime == null || endTime == null) {
+            return createEmptyEnergyReport();
+        }
+
+        // 校验时间参数
+        validateTimeParams(startTime, endTime, request.getTimeType());
+
+        // 生成列定义
+        List<EnergyReportResponse.ColumnItem> columnList = generateEnergyReportColumns(request.getTimeType(), startTime, endTime);
+
+        // 生成数据列表(按设备分组的二维数组)
+        List<List<EnergyReportResponse.ValueItem>> valueList = generateEnergyReportData(request, startTime, endTime);
+
+        EnergyReportResponse response = new EnergyReportResponse();
+        response.setColumnList(columnList);
+        response.setValueList(valueList);
+
+        return response;
+    }
+
+    @Override
+    public ItemReportResponse getItemReport(ItemReportRequest request) {
+        if (request == null || request.getItemCodes() == null || request.getItemCodes().isEmpty()) {
+            return createEmptyItemReport();
+        }
+
+        // 解析时间
+        LocalDateTime startTime = parseDateTime(request.getStartTime());
+        LocalDateTime endTime = parseDateTime(request.getEndTime());
+        if (startTime == null || endTime == null) {
+            return createEmptyItemReport();
+        }
+
+        // 校验时间参数
+        validateTimeParams(startTime, endTime, request.getTimeType());
+
+        // 生成标题列
+        List<ItemReportResponse.TitleItem> titleList = generateItemReportTitles(request.getTimeType(), startTime, endTime);
+
+        // 生成数据列表
+        List<Map<String, Object>> dataList = generateItemReportData(request, startTime, endTime);
+
+        ItemReportResponse response = new ItemReportResponse();
+        response.setTitleList(titleList);
+        response.setDataList(dataList);
+
+        return response;
+    }
+
+    @Override
+    public SpaceReportResponse getSpaceReport(SpaceReportRequest request) {
+        if (request == null || request.getSpaces() == null || request.getSpaces().isEmpty()) {
+            return createEmptySpaceReport();
+        }
+
+        // 解析时间
+        LocalDateTime startTime = parseDateTime(request.getStartTime());
+        LocalDateTime endTime = parseDateTime(request.getEndTime());
+        if (startTime == null || endTime == null) {
+            return createEmptySpaceReport();
+        }
+
+        // 校验时间参数
+        validateTimeParams(startTime, endTime, request.getTimeType());
+
+        // 生成列定义
+        List<EnergyReportResponse.ColumnItem> columnList = generateSpaceReportColumns(request.getTimeType(), startTime, endTime);
+
+        // 生成数据列表(按区域/空间分组的二维数组)
+        List<List<Map<String, Object>>> valueList = generateSpaceReportData(request, startTime, endTime);
+
+        // 计算总计
+        Number total = calculateSpaceReportTotal(valueList);
+
+        SpaceReportResponse response = new SpaceReportResponse();
+        response.setColumnList(columnList);
+        response.setValueList(valueList);
+        response.setTotal(total);
+
+        return response;
+    }
+
+    @Override
+    public void exportEnergyReport(EnergyReportRequest request, HttpServletResponse response) {
+        EnergyReportResponse report = getEnergyReport(request);
+        List<EnergyReportResponse.ColumnItem> columns = report.getColumnList() != null
+                ? report.getColumnList() : Collections.emptyList();
+        List<List<EnergyReportResponse.ValueItem>> groupedRows = report.getValueList() != null
+                ? report.getValueList() : Collections.emptyList();
+        writeEnergyReportExcel(response, buildExportFileName("能源报表"), columns, groupedRows);
+    }
+
+    @Override
+    public void exportSpaceReport(SpaceReportRequest request, HttpServletResponse response) {
+        SpaceReportResponse report = getSpaceReport(request);
+        List<EnergyReportResponse.ColumnItem> columns = report.getColumnList() != null
+                ? report.getColumnList() : Collections.emptyList();
+        List<List<Map<String, Object>>> groupedRows = report.getValueList() != null
+                ? report.getValueList() : Collections.emptyList();
+        writeSpaceReportExcel(response, buildExportFileName("区域报表"), columns, groupedRows);
+    }
+
+    // ==================== 能源报表私有方法 ====================
+
+    /**
+     * 生成能源报表列定义
+     */
+    private List<EnergyReportResponse.ColumnItem> generateEnergyReportColumns(String timeType, LocalDateTime startTime, LocalDateTime endTime) {
+        List<EnergyReportResponse.ColumnItem> columns = new ArrayList<>();
+
+        // 固定列
+        columns.add(createColumn("设备名称", "deviceName", true));
+        columns.add(createColumn("通讯地址", "commAddress", true));
+        columns.add(createColumn("功能点", "identifier", true));
+        columns.add(createColumn("合计", "total", true));
+
+        // 自定义查询仅返回合计,不生成动态时间列
+        if (isCustomTimeType(timeType)) {
+            return columns;
+        }
+
+        // 动态时间列
+        List<String> timeLabels = generateTimeLabels(timeType, startTime, endTime);
+        for (String label : timeLabels) {
+            columns.add(createColumn(label, "_" + label.replace("时", "").replace("日", "").replace("月", ""), false));
+        }
+
+        return columns;
+    }
+
+    /**
+     * 生成能源报表数据(按设备分组的二维数组)
+     * 外层 List 代表不同的设备
+     * 内层 List 包含该设备下所有请求的功能点(属性)数据
+     */
+    private List<List<EnergyReportResponse.ValueItem>> generateEnergyReportData(EnergyReportRequest request, LocalDateTime startTime, LocalDateTime endTime) {
+        List<List<EnergyReportResponse.ValueItem>> groupedValueList = new ArrayList<>();
+
+        // 获取设备uuid列表
+        List<Integer> deviceIds = request.getDeviceList().stream()
+                .filter(device -> device != null && device.getId() != null)
+                .map(EnergyReportRequest.DeviceItem::getId)
+                .collect(Collectors.toList());
+        List<String> deviceUuids = getUuidList(deviceIds);
+
+        // 查询tsdb历史数据
+        HistorysInnerRequestVO historyRequestVO = new HistorysInnerRequestVO();
+        historyRequestVO.setDeviceuuid(deviceUuids);
+        historyRequestVO.setStartTime(request.getStartTime());
+        historyRequestVO.setEndTime(request.getEndTime());
+        historyRequestVO.setMetrics(request.getFuncList() != null && !request.getFuncList().isEmpty()
+                ? request.getFuncList().stream().map(EnergyReportRequest.FuncItem::getIdentifier)
+                .map(String::toLowerCase)
+                .collect(Collectors.toList())
+                : new ArrayList<>());
+        log.info("历史数据接口请求:{}", historyRequestVO);
+        ApiResult<List<HistorysInnerResultVO>> listHistoryData = remoteTsdbProxyService.queryHistoryDeviceData(historyRequestVO);
+
+        // 判断历史数据是否为空
+        boolean hasData = !isHistoryDataEmpty(listHistoryData);
+        //log.warn("历史数据为空,设备uuid:{}", deviceUuids);
+        log.info("历史数据接口返回:{}", listHistoryData);
+
+        // 如果有数据,解析TSDB返回的结果
+        Map<String, Map<String, List<Map<String, Object>>>> tsdbDataMap = new HashMap<>();
+        Map<String, String> deviceIdMap = new HashMap<>(); // deviceuuid -> device_id
+        if (hasData) {
+            tsdbDataMap = parseTsdbData(listHistoryData.getData());
+            deviceIdMap = extractDeviceIdMap(listHistoryData.getData());
+        }
+        log.info("设备id映射:{}", deviceIdMap);
+        log.info("tsdb数据:{}", tsdbDataMap);
+
+        boolean customQuery = isCustomTimeType(request.getTimeType());
+
+        for (EnergyReportRequest.DeviceItem device : request.getDeviceList()) {
+            if (device == null || StringUtils.isEmpty(device.getId())) {
+                continue;
+            }
+
+            // 为当前设备创建一个子列表
+            List<EnergyReportResponse.ValueItem> deviceValueList = new ArrayList<>();
+
+            // 获取设备UUID用于匹配TSDB数据
+            DmpDevice dmpDevice = dmpDeviceMapper.selectById(device.getId());
+            String deviceUuid = dmpDevice != null ? dmpDevice.getDeviceUuid() : null;
+
+            // 如果没有功能点列表,为设备创建一个默认记录
+            if (request.getFuncList() == null || request.getFuncList().isEmpty()) {
+                if (customQuery) {
+                    String total = ENERGY_NO_DATA;
+                    if (hasData && deviceUuid != null && tsdbDataMap.containsKey(deviceUuid)) {
+                        Map<String, List<Map<String, Object>>> deviceMetrics = tsdbDataMap.get(deviceUuid);
+                        Map.Entry<String, List<Map<String, Object>>> firstMetric = deviceMetrics.entrySet().iterator().next();
+                        total = calculateCustomPeriodTotal(
+                                firstMetric.getValue(), firstMetric.getKey(), startTime, endTime);
+                    }
+                    deviceValueList.add(buildEnergyValueItem(device, deviceUuid, deviceIdMap, "-", total, null));
+                } else {
+                    Map<String, String> timeData;
+                    String total;
+                    if (hasData && deviceUuid != null && tsdbDataMap.containsKey(deviceUuid)) {
+                        Map<String, List<Map<String, Object>>> deviceMetrics = tsdbDataMap.get(deviceUuid);
+                        Map.Entry<String, List<Map<String, Object>>> firstMetric = deviceMetrics.entrySet().iterator().next();
+                        timeData = buildEnergyTimeDataForMetricItems(
+                                firstMetric.getValue(), firstMetric.getKey(),
+                                request.getTimeType(), startTime, endTime);
+                        total = calculateReportTotal(
+                                timeData, firstMetric.getKey(), firstMetric.getValue(), startTime, endTime);
+                    } else {
+                        timeData = generateEmptyTimeData(request.getTimeType(), startTime, endTime);
+                        total = ENERGY_NO_DATA;
+                    }
+                    deviceValueList.add(buildEnergyValueItem(device, deviceUuid, deviceIdMap, "-", total, timeData));
+                }
+            } else {
+                for (EnergyReportRequest.FuncItem func : request.getFuncList()) {
+                    if (func == null || StringUtils.isEmpty(func.getIdentifier())) {
+                        continue;
+                    }
+
+                    String identifier = func.getIdentifierName() != null ? func.getIdentifierName() : func.getIdentifier();
+                    if (customQuery) {
+                        String total = ENERGY_NO_DATA;
+                        if (hasData && deviceUuid != null && tsdbDataMap.containsKey(deviceUuid)) {
+                            List<Map<String, Object>> metricItems = getMetricItemsIgnoreCase(
+                                    tsdbDataMap.get(deviceUuid), func.getIdentifier());
+                            total = calculateCustomPeriodTotal(
+                                    metricItems, func.getIdentifier(), startTime, endTime);
+                        }
+                        deviceValueList.add(buildEnergyValueItem(device, deviceUuid, deviceIdMap, identifier, total, null));
+                    } else {
+                        Map<String, String> timeData;
+                        String total;
+                        List<Map<String, Object>> metricItems = null;
+                        if (hasData && deviceUuid != null && tsdbDataMap.containsKey(deviceUuid)) {
+                            metricItems = getMetricItemsIgnoreCase(tsdbDataMap.get(deviceUuid), func.getIdentifier());
+                            timeData = buildTimeDataFromTsdbForMetric(
+                                    tsdbDataMap.get(deviceUuid),
+                                    func.getIdentifier(),
+                                    request.getTimeType(),
+                                    startTime,
+                                    endTime);
+                            total = calculateReportTotal(
+                                    timeData, func.getIdentifier(), metricItems, startTime, endTime);
+                        } else {
+                            timeData = generateEmptyTimeData(request.getTimeType(), startTime, endTime);
+                            total = ENERGY_NO_DATA;
+                        }
+                        deviceValueList.add(buildEnergyValueItem(device, deviceUuid, deviceIdMap, identifier, total, timeData));
+                    }
+                }
+            }
+
+            groupedValueList.add(deviceValueList);
+        }
+
+        return groupedValueList;
+    }
+
+    private EnergyReportResponse.ValueItem buildEnergyValueItem(EnergyReportRequest.DeviceItem device,
+                                                                String deviceUuid,
+                                                                Map<String, String> deviceIdMap,
+                                                                String identifier,
+                                                                String total,
+                                                                Map<String, String> timeData) {
+        EnergyReportResponse.ValueItem item = new EnergyReportResponse.ValueItem();
+        item.setDeviceId(deviceIdMap.getOrDefault(deviceUuid, null));
+        item.setDeviceName(device.getName());
+        item.setCommAddress(device.getCommAddress());
+        item.setIdentifier(identifier);
+        item.setTotal(total);
+        item.setTimeData(timeData);
+        return item;
+    }
+
+    private boolean isCustomTimeType(String timeType) {
+        return timeType != null && "custom".equalsIgnoreCase(timeType);
+    }
+
+    /**
+     * 自定义查询合计:按指标类型聚合(累计类求各分段差值之和,瞬时类求全区间平均)
+     */
+    private String calculateCustomPeriodTotal(List<Map<String, Object>> metricItems,
+                                              String metricIdentifier,
+                                              LocalDateTime startTime,
+                                              LocalDateTime endTime) {
+        if (metricItems == null || metricItems.isEmpty()) {
+            return ENERGY_NO_DATA;
+        }
+        String segmentTimeType = resolveSegmentTimeTypeForCustom(startTime, endTime);
+        if ("custom_range".equals(segmentTimeType)) {
+            return calculateEnergySegmentValue(metricItems, metricIdentifier, startTime, endTime, true);
+        }
+        Map<String, String> timeData = buildEnergyTimeDataForMetricItems(
+                metricItems, metricIdentifier, segmentTimeType, startTime, endTime);
+        return calculateReportTotal(timeData, metricIdentifier, metricItems, startTime, endTime);
+    }
+
+    /**
+     * 自定义查询推断分段粒度,保证与 month/date/year 的合计逻辑一致
+     */
+    private String resolveSegmentTimeTypeForCustom(LocalDateTime startTime, LocalDateTime endTime) {
+        if (startTime.toLocalDate().equals(endTime.toLocalDate())) {
+            return "date";
+        }
+        if (startTime.getYear() == endTime.getYear() && startTime.getMonthValue() == endTime.getMonthValue()) {
+            return "month";
+        }
+        if (startTime.getYear() == endTime.getYear()) {
+            return "year";
+        }
+        return "custom_range";
+    }
+
+    private Map<String, String> buildEnergyTimeDataForMetricItems(List<Map<String, Object>> metricItems,
+                                                                  String metricIdentifier,
+                                                                  String timeType,
+                                                                  LocalDateTime startTime,
+                                                                  LocalDateTime endTime) {
+        Map<String, String> timeData = new LinkedHashMap<>();
+        List<TimeSegmentBound> segments = buildReportTimeSegments(timeType, startTime, endTime);
+        for (TimeSegmentBound segment : segments) {
+            String key = toEnergyTimeDataKey(segment.getLabel());
+            timeData.put(key, calculateEnergySegmentValue(
+                    metricItems, metricIdentifier,
+                    segment.getStartInclusive(), segment.getEndExclusive(), false));
+        }
+        return timeData;
+    }
+
+    private String toEnergyTimeDataKey(String label) {
+        return "_" + label.replace("时", "").replace("日", "").replace("月", "");
+    }
+
+    private MetricAggregateType resolveMetricAggregateType(String identifier) {
+        if (!StringUtils.hasText(identifier)) {
+            return MetricAggregateType.INSTANTANEOUS_AVG;
+        }
+        if (CUMULATIVE_ENERGY_METRICS.contains(identifier.toLowerCase())) {
+            return MetricAggregateType.CUMULATIVE_DELTA;
+        }
+        return MetricAggregateType.INSTANTANEOUS_AVG;
+    }
+
+    /**
+     * 单个时间段聚合:累计类取差值,瞬时类取平均(date 按小时、month 按天、year 按月)
+     */
+    private String calculateEnergySegmentValue(List<Map<String, Object>> metricItems,
+                                               String metricIdentifier,
+                                               LocalDateTime startInclusive,
+                                               LocalDateTime endExclusive,
+                                               boolean endInclusive) {
+        TreeMap<LocalDateTime, BigDecimal> readings = endInclusive
+                ? parseMetricReadingsInRangeInclusive(metricItems, startInclusive, endExclusive)
+                : parseMetricReadingsInRange(metricItems, startInclusive, endExclusive);
+        if (readings.isEmpty()) {
+            return ENERGY_NO_DATA;
+        }
+        if (resolveMetricAggregateType(metricIdentifier) == MetricAggregateType.CUMULATIVE_DELTA) {
+            return calculateCumulativeSegmentDelta(readings);
+        }
+        return calculateInstantaneousSegmentAverage(readings);
+    }
+
+    /** 累计表计:时段末次读数 - 首次读数(至少 2 条读数) */
+    private String calculateCumulativeSegmentDelta(TreeMap<LocalDateTime, BigDecimal> readings) {
+        if (readings.size() < 2) {
+            return ENERGY_NO_DATA;
+        }
+        BigDecimal first = readings.firstEntry().getValue();
+        BigDecimal last = readings.lastEntry().getValue();
+        BigDecimal usage = last.subtract(first);
+        if (usage.compareTo(BigDecimal.ZERO) < 0) {
+            usage = last;
+        }
+        return formatEnergyReportValue(usage);
+    }
+
+    /** 瞬时量:时段内读数算术平均 */
+    private String calculateInstantaneousSegmentAverage(TreeMap<LocalDateTime, BigDecimal> readings) {
+        BigDecimal sum = BigDecimal.ZERO;
+        for (BigDecimal value : readings.values()) {
+            sum = sum.add(value);
+        }
+        return formatEnergyReportValue(sum.divide(BigDecimal.valueOf(readings.size()), 4, RoundingMode.HALF_UP));
+    }
+
+    private String formatEnergyReportValue(BigDecimal value) {
+        return value.setScale(2, RoundingMode.HALF_UP).toPlainString();
+    }
+
+    private List<Map<String, Object>> getMetricItemsIgnoreCase(Map<String, List<Map<String, Object>>> metricDataMap,
+                                                               String identifier) {
+        if (metricDataMap == null || !StringUtils.hasText(identifier)) {
+            return null;
+        }
+        List<Map<String, Object>> items = metricDataMap.get(identifier);
+        if (items != null) {
+            return items;
+        }
+        for (Map.Entry<String, List<Map<String, Object>>> entry : metricDataMap.entrySet()) {
+            if (identifier.equalsIgnoreCase(entry.getKey())) {
+                return entry.getValue();
+            }
+        }
+        return null;
+    }
+
+    private TreeMap<LocalDateTime, BigDecimal> parseMetricReadingsInRange(List<Map<String, Object>> metricItems,
+                                                                          LocalDateTime startInclusive,
+                                                                          LocalDateTime endExclusive) {
+        TreeMap<LocalDateTime, BigDecimal> readings = new TreeMap<>();
+        if (metricItems == null || metricItems.isEmpty() || !startInclusive.isBefore(endExclusive)) {
+            return readings;
+        }
+
+        for (Map<String, Object> item : metricItems) {
+            if (item == null || !item.containsKey("timestamp") || !item.containsKey("value")) {
+                continue;
+            }
+            try {
+                LocalDateTime timestamp = parseMetricTimestamp(item.get("timestamp"));
+                if (timestamp == null || timestamp.isBefore(startInclusive) || !timestamp.isBefore(endExclusive)) {
+                    continue;
+                }
+                readings.put(timestamp, new BigDecimal(item.get("value").toString()));
+            } catch (Exception e) {
+                log.warn("解析能源报表历史读数失败:{}", item, e);
+            }
+        }
+        return readings;
+    }
+
+    private TreeMap<LocalDateTime, BigDecimal> parseMetricReadingsInRangeInclusive(List<Map<String, Object>> metricItems,
+                                                                                   LocalDateTime startInclusive,
+                                                                                   LocalDateTime endInclusive) {
+        TreeMap<LocalDateTime, BigDecimal> readings = new TreeMap<>();
+        if (metricItems == null || metricItems.isEmpty()) {
+            return readings;
+        }
+
+        for (Map<String, Object> item : metricItems) {
+            if (item == null || !item.containsKey("timestamp") || !item.containsKey("value")) {
+                continue;
+            }
+            try {
+                LocalDateTime timestamp = parseMetricTimestamp(item.get("timestamp"));
+                if (timestamp == null || timestamp.isBefore(startInclusive) || timestamp.isAfter(endInclusive)) {
+                    continue;
+                }
+                readings.put(timestamp, new BigDecimal(item.get("value").toString()));
+            } catch (Exception e) {
+                log.warn("解析能源报表历史读数失败:{}", item, e);
+            }
+        }
+        return readings;
+    }
+
+    private LocalDateTime parseMetricTimestamp(Object timestampObj) {
+        if (timestampObj == null) {
+            return null;
+        }
+        String timestampStr = timestampObj.toString();
+        if (timestampStr.endsWith(".0")) {
+            timestampStr = timestampStr.substring(0, timestampStr.length() - 2);
+        }
+        return parseDateTime(timestampStr);
+    }
+
+    /**
+     * 生成空的时间数据结构(用于无数据时填充)
+     */
+    private Map<String, String> generateEmptyTimeData(String timeType, LocalDateTime startTime, LocalDateTime endTime) {
+        Map<String, String> data = new LinkedHashMap<>();
+        List<String> timeLabels = generateTimeLabels(timeType, startTime, endTime);
+        for (String label : timeLabels) {
+            data.put(toEnergyTimeDataKey(label), ENERGY_ZERO);
+        }
+        return data;
+    }
+
+    /**
+     * 计算合计:累计类为各时段差值之和,瞬时类为查询区间内全量读数平均
+     */
+    private String calculateReportTotal(Map<String, String> timeData,
+                                        String metricIdentifier,
+                                        List<Map<String, Object>> metricItems,
+                                        LocalDateTime startTime,
+                                        LocalDateTime endTime) {
+        if (resolveMetricAggregateType(metricIdentifier) == MetricAggregateType.CUMULATIVE_DELTA) {
+            return calculateCumulativeTotal(timeData);
+        }
+        return calculateInstantaneousPeriodAverage(metricItems, startTime, endTime);
+    }
+
+    /**
+     * 累计类合计:各时段差值之和
+     */
+    private String calculateCumulativeTotal(Map<String, String> timeData) {
+        if (timeData == null || timeData.isEmpty()) {
+            return ENERGY_NO_DATA;
+        }
+
+        BigDecimal sum = BigDecimal.ZERO;
+        boolean hasNumeric = false;
+        boolean allMissing = true;
+        for (String value : timeData.values()) {
+            if (ENERGY_NO_DATA.equals(value)) {
+                continue;
+            }
+            allMissing = false;
+            try {
+                sum = sum.add(new BigDecimal(value));
+                hasNumeric = true;
+            } catch (NumberFormatException e) {
+                // 忽略无效数据
+            }
+        }
+
+        if (allMissing) {
+            return ENERGY_NO_DATA;
+        }
+        return hasNumeric ? formatEnergyReportValue(sum) : ENERGY_NO_DATA;
+    }
+
+    /**
+     * 瞬时类合计:查询区间内所有读数的算术平均
+     */
+    private String calculateInstantaneousPeriodAverage(List<Map<String, Object>> metricItems,
+                                                       LocalDateTime startTime,
+                                                       LocalDateTime endTime) {
+        if (metricItems == null || metricItems.isEmpty()) {
+            return ENERGY_NO_DATA;
+        }
+        TreeMap<LocalDateTime, BigDecimal> readings = parseMetricReadingsInRangeInclusive(
+                metricItems, startTime, endTime);
+        if (readings.isEmpty()) {
+            return ENERGY_NO_DATA;
+        }
+        return calculateInstantaneousSegmentAverage(readings);
+    }
+
+    // ==================== TSDB数据解析方法 ====================
+
+    /**
+     * 解析TSDB返回的数据
+     * 返回结构:Map<deviceuuid, Map<metric, List<metricItems>>>
+     */
+    private Map<String, Map<String, List<Map<String, Object>>>> parseTsdbData(List<HistorysInnerResultVO> tsdbResultList) {
+        Map<String, Map<String, List<Map<String, Object>>>> dataMap = new HashMap<>();
+
+        if (tsdbResultList == null || tsdbResultList.isEmpty()) {
+            return dataMap;
+        }
+
+        for (HistorysInnerResultVO result : tsdbResultList) {
+            if (result == null || !StringUtils.hasText(result.getDeviceuuid())) {
+                continue;
+            }
+
+            String deviceUuid = result.getDeviceuuid();
+            Map<String, List<Map<String, Object>>> metricMap = new HashMap<>();
+
+            if (result.getMetrics() != null && !result.getMetrics().isEmpty()) {
+                for (MetricVO metricVO : result.getMetrics()) {
+                    if (metricVO != null && StringUtils.hasText(metricVO.getMetric())) {
+                        metricMap.put(metricVO.getMetric(), metricVO.getMetricItems());
+                    }
+                }
+            }
+
+            dataMap.put(deviceUuid, metricMap);
+        }
+
+        return dataMap;
+    }
+
+    /**
+     * 从TSDB返回结果中提取device_id映射
+     * 返回结构:Map<deviceuuid, device_id>
+     */
+    private Map<String, String> extractDeviceIdMap(List<HistorysInnerResultVO> tsdbResultList) {
+        Map<String, String> deviceIdMap = new HashMap<>();
+
+        if (tsdbResultList == null || tsdbResultList.isEmpty()) {
+            return deviceIdMap;
+        }
+
+        for (HistorysInnerResultVO result : tsdbResultList) {
+            if (result == null || !StringUtils.hasText(result.getDeviceuuid())) {
+                continue;
+            }
+
+            String deviceUuid = result.getDeviceuuid();
+            // 从tags中获取device_id
+            if (result.getTags() != null && result.getTags().containsKey("device_id")) {
+                String deviceId = (String) result.getTags().get("device_id");
+                deviceIdMap.put(deviceUuid, deviceId);
+            }
+        }
+
+        return deviceIdMap;
+    }
+
+    /**
+     * 从TSDB数据中构建时间数据(取第一个功能点)
+     */
+    private Map<String, String> buildTimeDataFromTsdb(Map<String, List<Map<String, Object>>> metricDataMap,
+                                                      String timeType, LocalDateTime startTime, LocalDateTime endTime) {
+        if (metricDataMap == null || metricDataMap.isEmpty()) {
+            return generateEmptyTimeData(timeType, startTime, endTime);
+        }
+        Map.Entry<String, List<Map<String, Object>>> firstMetric = metricDataMap.entrySet().iterator().next();
+        return buildEnergyTimeDataForMetricItems(
+                firstMetric.getValue(), firstMetric.getKey(), timeType, startTime, endTime);
+    }
+
+    /**
+     * 从TSDB数据中构建指定功能点的时间数据
+     */
+    private Map<String, String> buildTimeDataFromTsdbForMetric(Map<String, List<Map<String, Object>>> metricDataMap,
+                                                               String metric,
+                                                               String timeType,
+                                                               LocalDateTime startTime,
+                                                               LocalDateTime endTime) {
+        List<Map<String, Object>> metricItems = getMetricItemsIgnoreCase(metricDataMap, metric);
+        if (metricItems == null || metricItems.isEmpty()) {
+            Map<String, String> empty = new LinkedHashMap<>();
+            for (TimeSegmentBound segment : buildReportTimeSegments(timeType, startTime, endTime)) {
+                empty.put(toEnergyTimeDataKey(segment.getLabel()), ENERGY_NO_DATA);
+            }
+            return empty;
+        }
+        return buildEnergyTimeDataForMetricItems(metricItems, metric, timeType, startTime, endTime);
+    }
+
+    // ==================== 分项报表私有方法 ====================
+
+    /**
+     * 生成分项报表标题列
+     */
+    private List<ItemReportResponse.TitleItem> generateItemReportTitles(String dateType, LocalDateTime startTime, LocalDateTime endTime) {
+        List<ItemReportResponse.TitleItem> titles = new ArrayList<>();
+
+        // 固定列
+        titles.add(createTitleItem("name", true, "分项名称", null));
+        titles.add(createTitleItem("total", true, "合计", null));
+
+        // 动态时间列
+        List<String> timeLabels = generateTimeLabels(dateType, startTime, endTime);
+        int index = 1;
+        for (String label : timeLabels) {
+            titles.add(createTitleItem("_" + index, false, label, index));
+            index++;
+        }
+
+        return titles;
+    }
+
+    /**
+     * 生成分项报表数据
+     */
+    private List<Map<String, Object>> generateItemReportData(ItemReportRequest request, LocalDateTime startTime, LocalDateTime endTime) {
+        List<Map<String, Object>> dataList = new ArrayList<>();
+
+        for (ItemReportRequest.ItemCodeItem itemCode : request.getItemCodes()) {
+            if (itemCode == null || StringUtils.isEmpty(itemCode.getCode())) {
+                continue;
+            }
+
+            Map<String, Object> row = new LinkedHashMap<>();
+            row.put("name", itemCode.getName());
+
+            // 查询分项能耗数据(模拟)
+            Map<String, Number> timeData = queryItemEnergyData(itemCode.getCode(), request.getSpaceId(),
+                    startTime, endTime, request.getTimeType());
+
+            // 计算合计
+            Number total = calculateItemTotal(timeData);
+            row.put("total", total);
+
+            // 添加时间列数据
+            int index = 1;
+            for (Number value : timeData.values()) {
+                row.put("_" + index, value);
+                index++;
+            }
+
+            dataList.add(row);
+        }
+
+        return dataList;
+    }
+
+    /**
+     * 查询分项能耗数据(模拟实现)
+     */
+    private Map<String, Number> queryItemEnergyData(String itemCode, Long spaceId,
+                                                    LocalDateTime startTime, LocalDateTime endTime, String dateType) {
+        Map<String, Number> data = new LinkedHashMap<>();
+
+        // TODO: 实际应从数据库查询真实数据
+        List<String> timeLabels = generateTimeLabels(dateType, startTime, endTime);
+        for (String label : timeLabels) {
+            data.put(label, 0); // 模拟数据为0
+        }
+
+        return data;
+    }
+
+    /**
+     * 计算分项合计
+     */
+    private Number calculateItemTotal(Map<String, Number> timeData) {
+        if (timeData == null || timeData.isEmpty()) {
+            return 0;
+        }
+        return sumUsageValues(timeData.values());
+    }
+
+    // ==================== 区域报表私有方法 ====================
+
+    /**
+     * 生成区域报表列定义
+     */
+    private List<EnergyReportResponse.ColumnItem> generateSpaceReportColumns(String dateType, LocalDateTime startTime, LocalDateTime endTime) {
+        List<EnergyReportResponse.ColumnItem> columns = new ArrayList<>();
+
+        // 固定列
+        columns.add(createColumn("区域名称", "spaceName", true));
+        columns.add(createColumn("分项名称", "itemName", true));
+        columns.add(createColumn("合计", "total", true));
+
+        // 动态时间列(prop 与前端约定:date/month 保留「时/日」后缀,year 去掉「月」)
+        List<String> timeLabels = generateTimeLabels(dateType, startTime, endTime);
+        for (String label : timeLabels) {
+            columns.add(createColumn(label, toSpaceReportColumnProp(label, dateType), false));
+        }
+
+        return columns;
+    }
+
+    /**
+     * 生成区域报表数据(按区域/空间分组的二维数组)
+     * 外层 List 代表不同的区域
+     * 内层 List 包含该区域下所有设备的详细数据记录
+     */
+    private List<List<Map<String, Object>>> generateSpaceReportData(SpaceReportRequest request, LocalDateTime startTime, LocalDateTime endTime) {
+        List<List<Map<String, Object>>> groupedValueList = new ArrayList<>();
+        String timeType = request.getTimeType() != null ? request.getTimeType().toLowerCase() : "date";
+        List<String> timeLabels = generateTimeLabels(timeType, startTime, endTime);
+
+        for (SpaceReportRequest.SpaceItem space : request.getSpaces()) {
+            if (space == null || space.getId() == null) {
+                continue;
+            }
+
+            Map<String, Map<String, Number>> itemCodeTimeData = querySpaceItemEnergyData(space, request, startTime, endTime);
+            List<Map<String, Object>> spaceValueList = new ArrayList<>();
+
+            for (SpaceReportRequest.ItemCodeItem itemCode : request.getItemCodes()) {
+                if (itemCode == null || StringUtils.isEmpty(itemCode.getCode())) {
+                    continue;
+                }
+
+                Map<String, Number> timeData = itemCodeTimeData.getOrDefault(
+                        buildSpaceItemKey(itemCode),
+                        createEmptySpaceTimeData(timeType, startTime, endTime));
+
+                Map<String, Object> row = new LinkedHashMap<>();
+                row.put("spaceName", space.getName());
+                row.put("itemName", itemCode.getName());
+
+                List<Number> periodValues = new ArrayList<>();
+                for (String label : timeLabels) {
+                    Number value = timeData.getOrDefault(label, 0);
+                    Number roundedValue = toUsageDecimal(value);
+                    row.put(toSpaceReportValueKey(label), roundedValue.doubleValue());
+                    periodValues.add(roundedValue);
+                }
+                row.put("total", sumUsageValues(periodValues));
+                spaceValueList.add(row);
+            }
+
+            groupedValueList.add(spaceValueList);
+        }
+
+        return groupedValueList;
+    }
+
+    /**
+     * 查询区域分项能耗数据,按分项(code+name)-> 时间标签 -> 用量 返回
+     */
+    private Map<String, Map<String, Number>> querySpaceItemEnergyData(SpaceReportRequest.SpaceItem space,
+                                                                      SpaceReportRequest request,
+                                                                      LocalDateTime startTime,
+                                                                      LocalDateTime endTime) {
+        String timeType = request.getTimeType() != null ? request.getTimeType().toLowerCase() : "date";
+        List<TimeSegmentBound> timeSegments = buildReportTimeSegments(timeType, startTime, endTime);
+
+        Map<String, Map<String, Number>> result = new LinkedHashMap<>();
+        List<SpaceReportRequest.ItemCodeItem> requestedItems = request.getItemCodes() == null
+                ? Collections.emptyList()
+                : request.getItemCodes().stream()
+                .filter(item -> item != null && StringUtils.hasText(item.getCode()))
+                .collect(Collectors.toList());
+
+        for (SpaceReportRequest.ItemCodeItem item : requestedItems) {
+            result.put(buildSpaceItemKey(item), createEmptySpaceTimeData(timeType, startTime, endTime));
+        }
+
+        if (space == null || space.getId() == null || requestedItems.isEmpty()) {
+            return result;
+        }
+
+        List<Long> spaceIds = spaceIds(Collections.singletonList(space.getId()));
+        List<String> gatewayUuids = getGatewayUuidsBySpaceIds(spaceIds).stream()
+                .map(BaseSpaceGateway::getGatewayUuid)
+                .collect(Collectors.toList());
+        List<String> spaceDeviceUuids = getDeviceUuidsByGatewayIds(gatewayUuids).stream()
+                .map(DmpDevice::getDeviceUuid)
+                .collect(Collectors.toList());
+
+        if (spaceDeviceUuids.isEmpty()) {
+            log.warn("区域下无设备,spaceId:{}", space.getId());
+            return result;
+        }
+
+        Integer energyType = getEnergyTypeByDeviceUuid(spaceDeviceUuids.get(0));
+        Long productId = getProductIdByEnergyType(energyType);
+        if (productId != 360L) {
+            throw new BusinessException("暂不支持该设备类型");
+        }
+
+        Map<String, List<String>> itemCodeToDeviceUuids = getItemCodeByDeviceUuid(spaceDeviceUuids);
+
+        List<String> metrics = Collections.singletonList("totalactiveenergyf");
+        HistorysInnerRequestVO historyRequestVO = new HistorysInnerRequestVO();
+        historyRequestVO.setDeviceuuid(spaceDeviceUuids);
+        historyRequestVO.setStartTime(request.getStartTime());
+        historyRequestVO.setEndTime(request.getEndTime());
+        historyRequestVO.setMetrics(metrics);
+        log.info("历史数据接口请求:{}", historyRequestVO);
+        ApiResult<List<HistorysInnerResultVO>> listHistoryData = remoteTsdbProxyService.queryHistoryDeviceData(historyRequestVO);
+        log.info("历史数据接口返回:{}", listHistoryData);
+
+        if (isHistoryDataEmpty(listHistoryData)) {
+            log.warn("历史数据为空,设备uuid:{}", spaceDeviceUuids);
+            return result;
+        }
+
+        Map<String, Map<String, TreeMap<LocalDateTime, Double>>> deviceMetricTimeValueMap =
+                parseDeviceMetricTimeValueMap(listHistoryData.getData());
+
+        for (SpaceReportRequest.ItemCodeItem itemCodeItem : requestedItems) {
+            List<String> itemDeviceUuids = resolveSpaceItemDeviceUuids(spaceDeviceUuids, itemCodeToDeviceUuids, itemCodeItem);
+            if (itemDeviceUuids.isEmpty()) {
+                log.warn("分项未匹配到区域设备,spaceId:{},itemCode:{},itemName:{}",
+                        space.getId(), itemCodeItem.getCode(), itemCodeItem.getName());
+                continue;
+            }
+
+            Map<String, Number> timeUsageMap = result.get(buildSpaceItemKey(itemCodeItem));
+            for (String deviceUuid : itemDeviceUuids) {
+                Map<String, TreeMap<LocalDateTime, Double>> metricTimeValueMap = deviceMetricTimeValueMap.get(deviceUuid);
+                if (metricTimeValueMap == null) {
+                    continue;
+                }
+                TreeMap<LocalDateTime, Double> timeValueMap = metricTimeValueMap.get("totalactiveenergyf");
+                if (timeValueMap == null || timeValueMap.isEmpty()) {
+                    continue;
+                }
+
+                for (TimeSegmentBound segment : timeSegments) {
+                    double usage = calculateSegmentUsage(timeValueMap, segment.getStartInclusive(), segment.getEndExclusive());
+                    timeUsageMap.put(segment.getLabel(), addUsage(timeUsageMap.get(segment.getLabel()), usage).doubleValue());
+                }
+            }
+        }
+
+        return result;
+    }
+
+    private String buildSpaceItemKey(SpaceReportRequest.ItemCodeItem item) {
+        return item.getCode() + "::" + (item.getName() != null ? item.getName() : "");
+    }
+
+    /**
+     * 解析分项对应设备:优先按分项名称解析编码,再按 code 匹配,返回区域内全部关联设备
+     */
+    private List<String> resolveSpaceItemDeviceUuids(List<String> spaceDeviceUuids,
+                                                     Map<String, List<String>> itemCodeToDeviceUuids,
+                                                     SpaceReportRequest.ItemCodeItem itemCodeItem) {
+        Set<String> spaceDeviceSet = new HashSet<>(spaceDeviceUuids);
+        String requestCode = itemCodeItem.getCode().trim();
+
+        String resolvedCode = resolveEnergyItemDbCode(requestCode, itemCodeItem.getName());
+        if (StringUtils.hasText(resolvedCode)) {
+            List<String> matched = findDevicesByItemCode(resolvedCode, itemCodeToDeviceUuids, spaceDeviceSet);
+            if (!matched.isEmpty()) {
+                return matched;
+            }
+        }
+
+        List<String> matched = findDevicesByItemCode(requestCode, itemCodeToDeviceUuids, spaceDeviceSet);
+        if (!matched.isEmpty()) {
+            return matched;
+        }
+
+        return Collections.emptyList();
+    }
+
+    private List<String> findDevicesByItemCode(String itemCode,
+                                               Map<String, List<String>> itemCodeToDeviceUuids,
+                                               Set<String> spaceDeviceSet) {
+        List<String> matched = filterDevicesInSpace(itemCodeToDeviceUuids.get(itemCode), spaceDeviceSet);
+        if (!matched.isEmpty()) {
+            return matched;
+        }
+        return filterDevicesInSpace(
+                emsDeviceItemCodeMapper.selectDeviceUuidsByItemCode(SecurityUtils.getTenantId(), itemCode),
+                spaceDeviceSet);
+    }
+
+    private List<String> filterDevicesInSpace(List<String> deviceUuids, Set<String> spaceDeviceSet) {
+        if (deviceUuids == null || deviceUuids.isEmpty()) {
+            return Collections.emptyList();
+        }
+        return deviceUuids.stream()
+                .filter(StringUtils::hasText)
+                .map(String::trim)
+                .filter(spaceDeviceSet::contains)
+                .distinct()
+                .collect(Collectors.toList());
+    }
+
+    /**
+     * 将请求中的 code/identifier/name 解析为 ems_energy_item_code 表中的 code
+     */
+    private String resolveEnergyItemDbCode(String requestCode, String itemName) {
+        if (StringUtils.hasText(itemName)) {
+            EmsEnergyItemCode byName = emsEnergyItemCodeMapper.selectOne(
+                    Wrappers.lambdaQuery(EmsEnergyItemCode.class)
+                            .eq(EmsEnergyItemCode::getName, itemName)
+                            .last("LIMIT 1"));
+            if (byName != null) {
+                return byName.getCode();
+            }
+        }
+
+        if (StringUtils.hasText(requestCode)) {
+            EmsEnergyItemCode byCode = emsEnergyItemCodeMapper.selectOne(
+                    Wrappers.lambdaQuery(EmsEnergyItemCode.class)
+                            .eq(EmsEnergyItemCode::getCode, requestCode)
+                            .last("LIMIT 1"));
+            if (byCode != null) {
+                return byCode.getCode();
+            }
+
+            EmsEnergyItemCode byIdentifier = emsEnergyItemCodeMapper.selectOne(
+                    Wrappers.lambdaQuery(EmsEnergyItemCode.class)
+                            .eq(EmsEnergyItemCode::getIdentifier, requestCode)
+                            .last("LIMIT 1"));
+            if (byIdentifier != null) {
+                return byIdentifier.getCode();
+            }
+        }
+        return null;
+    }
+
+    private TreeMap<LocalDateTime, Double> extractSegmentReadings(TreeMap<LocalDateTime, Double> source,
+                                                                  LocalDateTime startInclusive,
+                                                                  LocalDateTime endExclusive) {
+        TreeMap<LocalDateTime, Double> result = new TreeMap<>();
+        if (source == null || source.isEmpty() || !startInclusive.isBefore(endExclusive)) {
+            return result;
+        }
+        for (Map.Entry<LocalDateTime, Double> entry : source.entrySet()) {
+            LocalDateTime ts = entry.getKey();
+            if (!ts.isBefore(startInclusive) && ts.isBefore(endExclusive)) {
+                result.put(ts, entry.getValue());
+            }
+        }
+        return result;
+    }
+
+    /**
+     * 解析 TSDB 历史数据为 设备 -> 指标 -> 时间 -> 值 结构
+     */
+    private Map<String, Map<String, TreeMap<LocalDateTime, Double>>> parseDeviceMetricTimeValueMap(
+            List<HistorysInnerResultVO> historyDataList) {
+        Map<String, Map<String, TreeMap<LocalDateTime, Double>>> deviceMetricTimeValueMap = new HashMap<>();
+
+        if (historyDataList == null || historyDataList.isEmpty()) {
+            return deviceMetricTimeValueMap;
+        }
+
+        for (HistorysInnerResultVO result : historyDataList) {
+            if (result == null || !StringUtils.hasText(result.getDeviceuuid())) {
+                continue;
+            }
+            String deviceUuid = result.getDeviceuuid();
+            Map<String, TreeMap<LocalDateTime, Double>> metricTimeValueMap =
+                    deviceMetricTimeValueMap.computeIfAbsent(deviceUuid, k -> new HashMap<>());
+
+            if (result.getMetrics() == null) {
+                continue;
+            }
+            for (MetricVO metric : result.getMetrics()) {
+                if (metric == null || !StringUtils.hasText(metric.getMetric()) || metric.getMetricItems() == null) {
+                    continue;
+                }
+                String metricName = metric.getMetric();
+                TreeMap<LocalDateTime, Double> timeValueMap =
+                        metricTimeValueMap.computeIfAbsent(metricName, k -> new TreeMap<>());
+
+                for (Map<String, Object> point : metric.getMetricItems()) {
+                    try {
+                        String timestampStr = point.get("timestamp").toString();
+                        if (timestampStr.endsWith(".0")) {
+                            timestampStr = timestampStr.substring(0, timestampStr.length() - 2);
+                        }
+                        LocalDateTime timestamp = LocalDateTime.parse(timestampStr, DATE_TIME_FORMATTER);
+                        Double value = Double.parseDouble(point.get("value").toString());
+                        timeValueMap.put(timestamp, value);
+                    } catch (Exception e) {
+                        log.error("解析历史数据失败,设备:{},指标:{},值:{}",
+                                deviceUuid, metricName, point.get("value"), e);
+                    }
+                }
+            }
+        }
+        return deviceMetricTimeValueMap;
+    }
+
+    /**
+     * 时间段用量 = 该时间段内最后一个读数 - 第一个读数(至少 2 条读数)
+     */
+    private double calculateSegmentUsage(TreeMap<LocalDateTime, Double> timeValueMap,
+                                         LocalDateTime startInclusive,
+                                         LocalDateTime endExclusive) {
+        if (timeValueMap == null || timeValueMap.isEmpty()) {
+            return 0;
+        }
+
+        TreeMap<LocalDateTime, Double> segmentData = extractSegmentReadings(timeValueMap, startInclusive, endExclusive);
+        if (segmentData.size() < 2) {
+            return 0;
+        }
+
+        double firstValue = segmentData.firstEntry().getValue();
+        double lastValue = segmentData.lastEntry().getValue();
+        return formatUsageValue(calculateReadingDelta(firstValue, lastValue));
+    }
+
+    private double calculateReadingDelta(double firstValue, double lastValue) {
+        double usage = lastValue - firstValue;
+        if (usage < 0) {
+            return lastValue;
+        }
+        return usage;
+    }
+
+    private double formatUsageValue(double usage) {
+        return toUsageDecimal(usage).doubleValue();
+    }
+
+    private static final int USAGE_SCALE = 2;
+
+    private BigDecimal toUsageDecimal(Number value) {
+        if (value == null) {
+            return BigDecimal.ZERO.setScale(USAGE_SCALE, RoundingMode.HALF_UP);
+        }
+        return BigDecimal.valueOf(value.doubleValue()).setScale(USAGE_SCALE, RoundingMode.HALF_UP);
+    }
+
+    private BigDecimal addUsage(Number a, Number b) {
+        return toUsageDecimal(a).add(toUsageDecimal(b)).setScale(USAGE_SCALE, RoundingMode.HALF_UP);
+    }
+
+    private double sumUsageValues(Iterable<? extends Number> values) {
+        BigDecimal sum = BigDecimal.ZERO;
+        if (values != null) {
+            for (Number value : values) {
+                sum = sum.add(toUsageDecimal(value));
+            }
+        }
+        return sum.setScale(USAGE_SCALE, RoundingMode.HALF_UP).doubleValue();
+    }
+
+    private Map<String, Number> createEmptySpaceTimeData(String timeType, LocalDateTime startTime, LocalDateTime endTime) {
+        Map<String, Number> data = new LinkedHashMap<>();
+        for (String label : generateTimeLabels(timeType, startTime, endTime)) {
+            data.put(label, 0);
+        }
+        return data;
+    }
+
+    /**
+     * 报表时间分段(左闭右开),与 columnList 标签一一对应
+     */
+    private static class TimeSegmentBound {
+        private final String label;
+        private final LocalDateTime startInclusive;
+        private final LocalDateTime endExclusive;
+
+        TimeSegmentBound(String label, LocalDateTime startInclusive, LocalDateTime endExclusive) {
+            this.label = label;
+            this.startInclusive = startInclusive;
+            this.endExclusive = endExclusive;
+        }
+
+        String getLabel() {
+            return label;
+        }
+
+        LocalDateTime getStartInclusive() {
+            return startInclusive;
+        }
+
+        LocalDateTime getEndExclusive() {
+            return endExclusive;
+        }
+    }
+
+    private List<TimeSegmentBound> buildReportTimeSegments(String timeType, LocalDateTime startTime, LocalDateTime endTime) {
+        List<TimeSegmentBound> segments = new ArrayList<>();
+        if (startTime == null || endTime == null || !startTime.isBefore(endTime)) {
+            return segments;
+        }
+
+        String type = timeType != null ? timeType.toLowerCase() : "date";
+        switch (type) {
+            case "date":
+                buildHourSegments(segments, startTime, endTime);
+                break;
+            case "month":
+                buildDaySegments(segments, startTime, endTime);
+                break;
+            case "year":
+                buildMonthSegments(segments, startTime, endTime);
+                break;
+            case "custom":
+                break;
+            default:
+                buildHourSegments(segments, startTime, endTime);
+                break;
+        }
+        return segments;
+    }
+
+    /** date:按小时分段 [HH:00, HH+1:00),与传参起止小时一致 */
+    private void buildHourSegments(List<TimeSegmentBound> segments, LocalDateTime startTime, LocalDateTime endTime) {
+        int startHour = startTime.getHour();
+        int endHour = endTime.getHour();
+        for (int hour = startHour; hour <= endHour; hour++) {
+            String label = hour + "时";
+            LocalDateTime hourStart = startTime.withHour(hour).withMinute(0).withSecond(0).withNano(0);
+            LocalDateTime hourEndExclusive = hourStart.plusHours(1);
+            LocalDateTime segmentStart = hourStart.isBefore(startTime) ? startTime : hourStart;
+            LocalDateTime segmentEndExclusive = hourEndExclusive.isAfter(endTime) ? endTime : hourEndExclusive;
+            if (segmentStart.isBefore(segmentEndExclusive)) {
+                segments.add(new TimeSegmentBound(label, segmentStart, segmentEndExclusive));
+            } else {
+                segments.add(new TimeSegmentBound(label, segmentStart, segmentStart.plusNanos(1)));
+            }
+        }
+    }
+
+    /** month:按自然日分段 [00:00, 次日00:00) */
+    private void buildDaySegments(List<TimeSegmentBound> segments, LocalDateTime startTime, LocalDateTime endTime) {
+        LocalDate current = startTime.toLocalDate();
+        LocalDate lastDate = resolveLastDateInRange(startTime, endTime);
+        while (!current.isAfter(lastDate)) {
+            String label = String.format("%02d日", current.getDayOfMonth());
+            LocalDateTime dayStart = current.atStartOfDay();
+            LocalDateTime dayEndExclusive = current.plusDays(1).atStartOfDay();
+            LocalDateTime segmentStart = dayStart.isBefore(startTime) ? startTime : dayStart;
+            LocalDateTime segmentEndExclusive = dayEndExclusive.isAfter(endTime) ? endTime : dayEndExclusive;
+            if (segmentStart.isBefore(segmentEndExclusive)) {
+                segments.add(new TimeSegmentBound(label, segmentStart, segmentEndExclusive));
+            }
+            current = current.plusDays(1);
+        }
+    }
+
+    /** year:按自然月分段 [月初00:00, 下月初00:00) */
+    private void buildMonthSegments(List<TimeSegmentBound> segments, LocalDateTime startTime, LocalDateTime endTime) {
+        int year = startTime.getYear();
+        int month = startTime.getMonthValue();
+        int endYear = endTime.getYear();
+        int endMonth = endTime.getMonthValue();
+
+        while (year < endYear || (year == endYear && month <= endMonth)) {
+            String label = month + "月";
+            LocalDateTime monthStart = LocalDateTime.of(year, month, 1, 0, 0, 0);
+            LocalDateTime monthEndExclusive = monthStart.plusMonths(1);
+            LocalDateTime segmentStart = monthStart.isBefore(startTime) ? startTime : monthStart;
+            LocalDateTime segmentEndExclusive = monthEndExclusive.isAfter(endTime) ? endTime : monthEndExclusive;
+            if (segmentStart.isBefore(segmentEndExclusive)) {
+                segments.add(new TimeSegmentBound(label, segmentStart, segmentEndExclusive));
+            }
+            month++;
+            if (month > 12) {
+                month = 1;
+                year++;
+            }
+        }
+    }
+
+    /**
+     * 结束时间为某日 00:00:00 时,表示不含该日,最后一天取前一日
+     */
+    private LocalDate resolveLastDateInRange(LocalDateTime startTime, LocalDateTime endTime) {
+        LocalDate endDate = endTime.toLocalDate();
+        if (endTime.toLocalTime().equals(LocalTime.MIDNIGHT) && endDate.isAfter(startTime.toLocalDate())) {
+            return endDate.minusDays(1);
+        }
+        return endDate;
+    }
+
+    private String toSpaceReportColumnProp(String label, String timeType) {
+        String type = timeType != null ? timeType.toLowerCase() : "date";
+        if ("year".equals(type)) {
+            return "_" + label.replace("月", "");
+        }
+        return "_" + label;
+    }
+
+    private String toSpaceReportValueKey(String label) {
+        String numPart = label.replace("时", "").replace("日", "").replace("月", "");
+        try {
+            return "_" + Integer.parseInt(numPart);
+        } catch (NumberFormatException e) {
+            return "_" + numPart;
+        }
+    }
+
+    /**
+     * 查询所有区域id(包含子区域id)
+     */
+    private List<Long> spaceIds(List<Long> spaceId) {
+        return baseSpaceMapper.selectList(
+                new LambdaQueryWrapper<BaseSpace>()
+                        .in(BaseSpace::getId, spaceId)
+                        .or().in(BaseSpace::getParentId, spaceId)
+        ).stream().map(BaseSpace::getId).collect(Collectors.toList());
+    }
+
+    /**
+     * 通过energy_type获取product_id
+     */
+    private Long getProductIdByEnergyType(Integer energyType) {
+        return emsProductEnergyTypeMapper.selectOne(
+                new LambdaQueryWrapper<EmsProductEnergyType>()
+                        .eq(EmsProductEnergyType::getEnergyType, energyType)
+        ).getProductId();
+    }
+
+    /**
+     * 通过device_uuid按item_code分组
+     */
+    private Map<String, List<String>> getItemCodeByDeviceUuid(List<String> deviceUuids) {
+        return emsDeviceItemCodeMapper.selectList(
+                new LambdaQueryWrapper<EmsDeviceItemCode>()
+                        .in(EmsDeviceItemCode::getDeviceUuid, deviceUuids)
+        ).stream().collect(Collectors.groupingBy(
+                EmsDeviceItemCode::getItemCode,
+                Collectors.mapping(
+                        EmsDeviceItemCode::getDeviceUuid,
+                        Collectors.toList()
+                )
+        ));
+    }
+
+    /**
+     * 通过device_uuid获取energy_type
+     */
+    private Integer getEnergyTypeByDeviceUuid(String deviceUuid) {
+        return emsDeviceItemCodeMapper.selectList(
+                new LambdaQueryWrapper<EmsDeviceItemCode>()
+                        .eq(EmsDeviceItemCode::getDeviceUuid, deviceUuid)
+        ).get(0).getEnergyType();
+    }
+
+    /**
+     * 根据网关id获取设备UUID列表
+     */
+    private List<DmpDevice> getDeviceUuidsByGatewayIds(List<String> gatewayIds) {
+        return new ArrayList<>(dmpDeviceMapper.selectList(
+                new LambdaQueryWrapper<DmpDevice>()
+                        .in(DmpDevice::getGatewayUuid, gatewayIds)));
+    }
+
+    /**
+     * 根据区域id获取网关UUID列表
+     */
+    private List<BaseSpaceGateway> getGatewayUuidsBySpaceIds(List<Long> spaceIds) {
+        return new ArrayList<>(baseSpaceGatewayMapper.selectList(
+                new LambdaQueryWrapper<BaseSpaceGateway>()
+                        .in(BaseSpaceGateway::getSpaceId, spaceIds)));
+    }
+
+    /**
+     * 计算区域报表总计(适配二维数组结构)
+     */
+    private Number calculateSpaceReportTotal(List<List<Map<String, Object>>> groupedValueList) {
+        if (groupedValueList == null || groupedValueList.isEmpty()) {
+            return 0;
+        }
+
+        List<Number> rowTotals = new ArrayList<>();
+        for (List<Map<String, Object>> group : groupedValueList) {
+            if (group == null || group.isEmpty()) {
+                continue;
+            }
+            for (Map<String, Object> row : group) {
+                Object total = row.get("total");
+                if (total instanceof Number) {
+                    rowTotals.add((Number) total);
+                }
+            }
+        }
+        return sumUsageValues(rowTotals);
+    }
+
+    // ==================== 通用工具方法 ====================
+
+    /**
+     * 校验时间参数
+     * 1. 结束时间不得早于开始时间
+     * 2. 按日查询:起止时间必须是同一天
+     * 3. 按月查询:起止时间必须是同一月
+     * 4. 按年查询:起止时间必须是同一年
+     */
+    private void validateTimeParams(LocalDateTime startTime, LocalDateTime endTime, String timeType) {
+        // 基本校验:结束时间不得早于开始时间
+        if (endTime.isBefore(startTime)) {
+            throw new BusinessException("结束时间不得早于开始时间");
+        }
+
+        String type = timeType != null ? timeType.toLowerCase() : "date";
+
+        switch (type) {
+            case "date":
+                // 按日查询:必须是同一天
+                if (!startTime.toLocalDate().isEqual(endTime.toLocalDate())) {
+                    throw new BusinessException("按日查询时,起止时间必须是同一天");
+                }
+                break;
+            case "month":
+                // 按月查询:必须是同一月
+                if (startTime.getYear() != endTime.getYear() ||
+                        startTime.getMonthValue() != endTime.getMonthValue()) {
+                    throw new BusinessException("按月查询时,起止时间必须是同一月");
+                }
+                break;
+            case "year":
+                // 按年查询:必须是同一年
+                if (startTime.getYear() != endTime.getYear()) {
+                    throw new BusinessException("按年查询时,起止时间必须是同一年");
+                }
+                break;
+            case "custom":
+                // 自定义查询:不进行时间校验
+                break;
+            default:
+                throw new BusinessException("查询时间类型错误!请重试");
+        }
+    }
+
+    /**
+     * 创建列定义
+     */
+    private EnergyReportResponse.ColumnItem createColumn(String label, String prop, Boolean fixed) {
+        EnergyReportResponse.ColumnItem column = new EnergyReportResponse.ColumnItem();
+        column.setLabel(label);
+        column.setProp(prop);
+        column.setFixed(fixed);
+        column.setChildren(null);
+        return column;
+    }
+
+    /**
+     * 创建标题项
+     */
+    private ItemReportResponse.TitleItem createTitleItem(String prop, Boolean fixed, String label, Integer value) {
+        ItemReportResponse.TitleItem item = new ItemReportResponse.TitleItem();
+        item.setProp(prop);
+        item.setFixed(fixed);
+        item.setLabel(label);
+        item.setValue(value);
+        return item;
+    }
+
+    /**
+     * 生成时间标签列表
+     * 规则:
+     * - 按日(date)查询 → 返回小时数据(根据起止时间的小时范围)
+     * - 按月(month)查询 → 返回日数据(01日-31日)
+     * - 按年(year)查询 → 返回月数据(1月-12月)
+     */
+    private List<String> generateTimeLabels(String timeType, LocalDateTime startTime, LocalDateTime endTime) {
+        if (startTime == null || endTime == null) {
+            return new ArrayList<>();
+        }
+        return buildReportTimeSegments(timeType, startTime, endTime).stream()
+                .map(TimeSegmentBound::getLabel)
+                .collect(Collectors.toList());
+    }
+
+    /**
+     * 解析日期时间字符串
+     */
+    private LocalDateTime parseDateTime(String dateTimeStr) {
+        if (!StringUtils.hasText(dateTimeStr)) {
+            return null;
+        }
+        try {
+            return LocalDateTime.parse(dateTimeStr, DATE_TIME_FORMATTER);
+        } catch (Exception e) {
+            return null;
+        }
+    }
+
+    /**
+     * 获取设备UUID列表
+     */
+    private List<String> getUuidList(List<Integer> deviceIds) {
+        return deviceIds.stream().map(deviceId -> {
+            DmpDevice device = dmpDeviceMapper.selectById(deviceId);
+            return device != null ? device.getDeviceUuid() : null;
+        }).collect(Collectors.toList());
+    }
+
+    private boolean isHistoryDataEmpty(ApiResult<?> result) {
+        return result == null
+                || !result.isSuccess()
+                || result.getStatus() != ApiResult.ResultStatus.SUCCESS
+                || result.getData() == null;
+    }
+
+    // ==================== 报表 Excel 导出 ====================
+
+    private String buildExportFileName(String prefix) {
+        return prefix + "_" + LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyyMMddHHmmss"));
+    }
+
+    private String resolveEnergyCellValue(EnergyReportResponse.ValueItem item,
+                                          EnergyReportResponse.ColumnItem column) {
+        if (item == null || column == null || !StringUtils.hasText(column.getProp())) {
+            return "";
+        }
+        String prop = column.getProp();
+        switch (prop) {
+            case "deviceName":
+                return formatExportCellValue(item.getDeviceName());
+            case "commAddress":
+                return formatExportCellValue(item.getCommAddress());
+            case "identifier":
+                return formatExportCellValue(item.getIdentifier());
+            case "total":
+                return formatExportCellValue(item.getTotal());
+            default:
+                Map<String, String> timeData = item.getTimeData();
+                if (timeData != null && timeData.containsKey(prop)) {
+                    return formatExportCellValue(timeData.get(prop));
+                }
+                return ENERGY_NO_DATA;
+        }
+    }
+
+    private String resolveSpaceCellValue(Map<String, Object> rowMap, EnergyReportResponse.ColumnItem column) {
+        if (rowMap == null || column == null || !StringUtils.hasText(column.getProp())) {
+            return "";
+        }
+        Object value = rowMap.get(column.getProp());
+        if (value == null && StringUtils.hasText(column.getLabel())) {
+            value = rowMap.get(toSpaceReportValueKey(column.getLabel()));
+        }
+        return formatExportCellValue(value);
+    }
+
+    private String formatExportCellValue(Object value) {
+        return value == null ? "" : value.toString();
+    }
+
+    private void writeEnergyReportExcel(HttpServletResponse response,
+                                        String fileName,
+                                        List<EnergyReportResponse.ColumnItem> columns,
+                                        List<List<EnergyReportResponse.ValueItem>> groupedRows) {
+        try (Workbook workbook = new XSSFWorkbook()) {
+            Sheet sheet = workbook.createSheet("能源报表");
+            CellStyle headerStyle = createExportHeaderStyle(workbook);
+            CellStyle textStyle = createExportDataStyle(workbook, HorizontalAlignment.LEFT);
+            CellStyle numberStyle = createExportDataStyle(workbook, HorizontalAlignment.CENTER);
+
+            writeExportHeaderRow(sheet, columns, headerStyle);
+
+            int deviceNameCol = findColumnIndex(columns, "deviceName");
+            int commAddressCol = findColumnIndex(columns, "commAddress");
+            int rowIndex = 1;
+            for (List<EnergyReportResponse.ValueItem> deviceRows : groupedRows) {
+                if (deviceRows == null || deviceRows.isEmpty()) {
+                    continue;
+                }
+                int groupStartRow = rowIndex;
+                for (EnergyReportResponse.ValueItem item : deviceRows) {
+                    Row row = sheet.createRow(rowIndex++);
+                    writeEnergyReportDataRow(row, item, columns, textStyle, numberStyle);
+                }
+                int groupEndRow = rowIndex - 1;
+                if (groupEndRow > groupStartRow) {
+                    mergeExportColumn(sheet, groupStartRow, groupEndRow, deviceNameCol);
+                    mergeExportColumn(sheet, groupStartRow, groupEndRow, commAddressCol);
+                }
+            }
+
+            autoSizeExportColumns(sheet, columns.size());
+            writeExcelToResponse(response, fileName, workbook);
+        } catch (IOException e) {
+            throw new BusinessException("导出失败", e);
+        }
+    }
+
+    private void writeSpaceReportExcel(HttpServletResponse response,
+                                       String fileName,
+                                       List<EnergyReportResponse.ColumnItem> columns,
+                                       List<List<Map<String, Object>>> groupedRows) {
+        try (Workbook workbook = new XSSFWorkbook()) {
+            Sheet sheet = workbook.createSheet("区域报表");
+            CellStyle headerStyle = createExportHeaderStyle(workbook);
+            CellStyle textStyle = createExportDataStyle(workbook, HorizontalAlignment.LEFT);
+            CellStyle numberStyle = createExportDataStyle(workbook, HorizontalAlignment.CENTER);
+
+            writeExportHeaderRow(sheet, columns, headerStyle);
+
+            int spaceNameCol = findColumnIndex(columns, "spaceName");
+            int rowIndex = 1;
+            for (List<Map<String, Object>> spaceRows : groupedRows) {
+                if (spaceRows == null || spaceRows.isEmpty()) {
+                    continue;
+                }
+                int groupStartRow = rowIndex;
+                for (Map<String, Object> rowMap : spaceRows) {
+                    Row row = sheet.createRow(rowIndex++);
+                    writeSpaceReportDataRow(row, rowMap, columns, textStyle, numberStyle);
+                }
+                int groupEndRow = rowIndex - 1;
+                if (groupEndRow > groupStartRow) {
+                    mergeExportColumn(sheet, groupStartRow, groupEndRow, spaceNameCol);
+                }
+            }
+
+            autoSizeExportColumns(sheet, columns.size());
+            writeExcelToResponse(response, fileName, workbook);
+        } catch (IOException e) {
+            throw new BusinessException("导出失败", e);
+        }
+    }
+
+    private void writeExportHeaderRow(Sheet sheet,
+                                      List<EnergyReportResponse.ColumnItem> columns,
+                                      CellStyle headerStyle) {
+        Row headerRow = sheet.createRow(0);
+        for (int i = 0; i < columns.size(); i++) {
+            Cell cell = headerRow.createCell(i);
+            cell.setCellValue(columns.get(i).getLabel());
+            cell.setCellStyle(headerStyle);
+        }
+    }
+
+    private void writeEnergyReportDataRow(Row row,
+                                          EnergyReportResponse.ValueItem item,
+                                          List<EnergyReportResponse.ColumnItem> columns,
+                                          CellStyle textStyle,
+                                          CellStyle numberStyle) {
+        for (int colIndex = 0; colIndex < columns.size(); colIndex++) {
+            EnergyReportResponse.ColumnItem column = columns.get(colIndex);
+            Cell cell = row.createCell(colIndex);
+            cell.setCellValue(resolveEnergyCellValue(item, column));
+            cell.setCellStyle(isExportTextColumn(column) ? textStyle : numberStyle);
+        }
+    }
+
+    private void writeSpaceReportDataRow(Row row,
+                                         Map<String, Object> rowMap,
+                                         List<EnergyReportResponse.ColumnItem> columns,
+                                         CellStyle textStyle,
+                                         CellStyle numberStyle) {
+        for (int colIndex = 0; colIndex < columns.size(); colIndex++) {
+            EnergyReportResponse.ColumnItem column = columns.get(colIndex);
+            Cell cell = row.createCell(colIndex);
+            cell.setCellValue(resolveSpaceCellValue(rowMap, column));
+            cell.setCellStyle(isExportTextColumn(column) ? textStyle : numberStyle);
+        }
+    }
+
+    private boolean isExportTextColumn(EnergyReportResponse.ColumnItem column) {
+        if (column == null || !StringUtils.hasText(column.getProp())) {
+            return true;
+        }
+        switch (column.getProp()) {
+            case "deviceName":
+            case "commAddress":
+            case "identifier":
+            case "spaceName":
+            case "itemName":
+                return true;
+            default:
+                return false;
+        }
+    }
+
+    private int findColumnIndex(List<EnergyReportResponse.ColumnItem> columns, String prop) {
+        for (int i = 0; i < columns.size(); i++) {
+            if (prop.equals(columns.get(i).getProp())) {
+                return i;
+            }
+        }
+        return -1;
+    }
+
+    private void mergeExportColumn(Sheet sheet, int firstRow, int lastRow, int columnIndex) {
+        if (columnIndex < 0 || firstRow >= lastRow) {
+            return;
+        }
+        sheet.addMergedRegion(new CellRangeAddress(firstRow, lastRow, columnIndex, columnIndex));
+    }
+
+    private void autoSizeExportColumns(Sheet sheet, int columnCount) {
+        for (int i = 0; i < columnCount; i++) {
+            sheet.autoSizeColumn(i);
+            int width = sheet.getColumnWidth(i);
+            sheet.setColumnWidth(i, Math.min(width + 512, 255 * 256));
+        }
+    }
+
+    private void writeExcelToResponse(HttpServletResponse response, String fileName, Workbook workbook)
+            throws IOException {
+        response.setContentType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet");
+        response.setCharacterEncoding("utf-8");
+        response.setHeader("Content-Disposition",
+                "attachment; filename=\"" + encodeExportFileName(fileName) + ".xlsx\"");
+        workbook.write(response.getOutputStream());
+        response.getOutputStream().flush();
+    }
+
+    private String encodeExportFileName(String fileName) {
+        try {
+            return URLEncoder.encode(fileName, StandardCharsets.UTF_8.name()).replace("+", "%20");
+        } catch (UnsupportedEncodingException e) {
+            return fileName;
+        }
+    }
+
+    private CellStyle createExportHeaderStyle(Workbook workbook) {
+        CellStyle style = createBorderedCellStyle(workbook);
+        Font font = workbook.createFont();
+        font.setBold(true);
+        style.setFont(font);
+        style.setAlignment(HorizontalAlignment.CENTER);
+        style.setVerticalAlignment(VerticalAlignment.CENTER);
+        style.setFillForegroundColor(IndexedColors.GREY_25_PERCENT.getIndex());
+        style.setFillPattern(FillPatternType.SOLID_FOREGROUND);
+        return style;
+    }
+
+    private CellStyle createExportDataStyle(Workbook workbook, HorizontalAlignment alignment) {
+        CellStyle style = createBorderedCellStyle(workbook);
+        style.setAlignment(alignment);
+        style.setVerticalAlignment(VerticalAlignment.CENTER);
+        return style;
+    }
+
+    private CellStyle createBorderedCellStyle(Workbook workbook) {
+        CellStyle style = workbook.createCellStyle();
+        style.setBorderTop(BorderStyle.THIN);
+        style.setBorderBottom(BorderStyle.THIN);
+        style.setBorderLeft(BorderStyle.THIN);
+        style.setBorderRight(BorderStyle.THIN);
+        return style;
+    }
+
+    // ==================== 空响应创建方法 ====================
+
+    private EnergyReportResponse createEmptyEnergyReport() {
+        EnergyReportResponse response = new EnergyReportResponse();
+        response.setColumnList(new ArrayList<>());
+        response.setValueList(new ArrayList<>());
+        return response;
+    }
+
+    private ItemReportResponse createEmptyItemReport() {
+        ItemReportResponse response = new ItemReportResponse();
+        response.setTitleList(new ArrayList<>());
+        response.setDataList(new ArrayList<>());
+        return response;
+    }
+
+    private SpaceReportResponse createEmptySpaceReport() {
+        SpaceReportResponse response = new SpaceReportResponse();
+        response.setColumnList(new ArrayList<>());
+        response.setValueList(new ArrayList<>());
+        response.setTotal(0);
+        return response;
+    }
 }

+ 20 - 0
service-ems/service-ems-biz/src/main/java/com/usky/ems/service/vo/EmsAveragePointVO.java

@@ -0,0 +1,20 @@
+package com.usky.ems.service.vo;
+
+import lombok.Data;
+
+/**
+ * 能耗分析-单时间点数据
+ */
+@Data
+public class EmsAveragePointVO {
+
+    /**
+     * 用量值,无数据时为 "-"
+     */
+    private String value;
+
+    /**
+     * 时间点,与 timeData 一致(如 2026年6月3日0时)
+     */
+    private String time;
+}

+ 48 - 0
service-ems/service-ems-biz/src/main/java/com/usky/ems/service/vo/EmsAverageRequest.java

@@ -0,0 +1,48 @@
+package com.usky.ems.service.vo;
+
+import lombok.Data;
+
+import java.util.List;
+
+/**
+ * 能耗分析-区域时序数据请求
+ */
+@Data
+public class EmsAverageRequest {
+
+    /**
+     * 开始时间,格式:yyyy-MM-dd HH:mm:ss
+     */
+    private String startTime;
+
+    /**
+     * 结束时间,格式:yyyy-MM-dd HH:mm:ss
+     */
+    private String endTime;
+
+    /**
+     * 时间粒度:hour、day、month、year
+     */
+    private String dateType;
+
+    /**
+     * 区域列表
+     */
+    private List<SpaceItem> spaces;
+
+    /**
+     * 查询类型:0-总量/KWh,1-单位面积用量(kWh/㎡)
+     */
+    private Integer type;
+
+    /**
+     * 能耗分项编码
+     */
+    private String code;
+
+    @Data
+    public static class SpaceItem {
+        private Long id;
+        private String name;
+    }
+}

+ 23 - 0
service-ems/service-ems-biz/src/main/java/com/usky/ems/service/vo/EmsAverageResponseVO.java

@@ -0,0 +1,23 @@
+package com.usky.ems.service.vo;
+
+import lombok.Data;
+
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * 能耗分析-区域时序数据响应
+ */
+@Data
+public class EmsAverageResponseVO {
+
+    /**
+     * 时间轴展示标签
+     */
+    private List<String> timeData = new ArrayList<>();
+
+    /**
+     * 各区域时序数据
+     */
+    private List<EmsAverageSpaceItemVO> dataList = new ArrayList<>();
+}

+ 17 - 0
service-ems/service-ems-biz/src/main/java/com/usky/ems/service/vo/EmsAverageSpaceItemVO.java

@@ -0,0 +1,17 @@
+package com.usky.ems.service.vo;
+
+import lombok.Data;
+
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * 能耗分析-区域时序数据
+ */
+@Data
+public class EmsAverageSpaceItemVO {
+
+    private Long spaceId;
+    private String name;
+    private List<EmsAveragePointVO> list = new ArrayList<>();
+}

+ 4 - 1
service-ems/service-ems-biz/src/main/java/com/usky/ems/service/vo/EmsCompareResponse.java

@@ -9,6 +9,9 @@ import java.util.List;
 public class EmsCompareResponse {
 
     private String energyTypeName;
-    private String dimension;
+    /** 查询粒度:D/M/Y */
+    private String timeDimension;
+    /** 时间轴标签,如日 2026-06-01…2026-06-25 */
+    private List<String> dimension = new ArrayList<>();
     private List<EmsCompareSeriesItemVO> series = new ArrayList<>();
 }

+ 93 - 0
service-ems/service-ems-biz/src/main/java/com/usky/ems/service/vo/EnergyReportRequest.java

@@ -0,0 +1,93 @@
+package com.usky.ems.service.vo;
+
+import lombok.Data;
+
+import java.util.List;
+
+/**
+ * 能源报表请求VO
+ */
+@Data
+public class EnergyReportRequest {
+
+    /**
+     * 开始时间,格式:yyyy-MM-dd HH:mm:ss
+     */
+    private String startTime;
+
+    /**
+     * 结束时间,格式:yyyy-MM-dd HH:mm:ss
+     */
+    private String endTime;
+
+    /**
+     * 能源类型:1-电,2-水,3-气
+     */
+    private Integer energyType;
+
+    /**
+     * 设备列表
+     */
+    private List<DeviceItem> deviceList;
+
+    /**
+     * 时间类型:hour-按小时,date-按日,month-按月,year-按年
+     */
+    private String timeType;
+
+    /**
+     * 功能点列表
+     */
+    private List<FuncItem> funcList;
+
+    @Data
+    public static class DeviceItem {
+        /**
+         * 通讯地址
+         */
+        private String commAddress;
+
+        /**
+         * 产品ID
+         */
+        private Long productId;
+
+        /**
+         * 设备名称
+         */
+        private String name;
+
+        /**
+         * 设备主键ID
+         */
+        private Integer id;
+    }
+
+    @Data
+    public static class FuncItem {
+        /**
+         * 产品ID列表
+         */
+        private List<Long> productIds;
+
+        /**
+         * 标识符
+         */
+        private String identifier;
+
+        /**
+         * 标识符名称
+         */
+        private String identifierName;
+
+        /**
+         * 标签
+         */
+        private String label;
+
+        /**
+         * 值
+         */
+        private String value;
+    }
+}

+ 81 - 0
service-ems/service-ems-biz/src/main/java/com/usky/ems/service/vo/EnergyReportResponse.java

@@ -0,0 +1,81 @@
+package com.usky.ems.service.vo;
+
+import lombok.Data;
+
+import java.util.List;
+
+/**
+ * 能源报表响应VO
+ */
+@Data
+public class EnergyReportResponse {
+
+    /**
+     * 列定义列表
+     */
+    private List<ColumnItem> columnList;
+
+    /**
+     * 数据值列表(按设备分组的二维数组)
+     * 外层 List 代表不同的设备
+     * 内层 List 包含该设备下所有请求的功能点(属性)数据
+     */
+    private List<List<ValueItem>> valueList;
+
+    @Data
+    public static class ColumnItem {
+        /**
+         * 列标签
+         */
+        private String label;
+
+        /**
+         * 列属性名
+         */
+        private String prop;
+
+        /**
+         * 是否固定列
+         */
+        private Boolean fixed;
+
+        /**
+         * 子列(用于多级表头)
+         */
+        private List<ColumnItem> children;
+    }
+
+    @Data
+    public static class ValueItem {
+        /**
+         * 设备ID
+         */
+        private String deviceId;
+
+        /**
+         * 设备名称
+         */
+        private String deviceName;
+
+        /**
+         * 通讯地址
+         */
+        private String commAddress;
+
+        /**
+         * 功能点标识符
+         */
+        private String identifier;
+
+        /**
+         * 合计值
+         */
+        private String total;
+
+        /**
+         * 动态时间列数据,key为 _0, _1, _2... 或 _1, _2...31 等
+         * 使用Map存储动态列
+         */
+        private java.util.Map<String, String> timeData;
+    }
+}

+ 50 - 0
service-ems/service-ems-biz/src/main/java/com/usky/ems/service/vo/ItemReportRequest.java

@@ -0,0 +1,50 @@
+package com.usky.ems.service.vo;
+
+import lombok.Data;
+
+import java.util.List;
+
+/**
+ * 分项报表请求VO
+ */
+@Data
+public class ItemReportRequest {
+
+    /**
+     * 空间ID
+     */
+    private Long spaceId;
+
+    /**
+     * 开始时间,格式:yyyy-MM-dd HH:mm:ss
+     */
+    private String startTime;
+
+    /**
+     * 结束时间,格式:yyyy-MM-dd HH:mm:ss
+     */
+    private String endTime;
+
+    /**
+     * 分项编码列表
+     */
+    private List<ItemCodeItem> itemCodes;
+
+    /**
+     * 日期类型:day-按日,month-按月,year-按年
+     */
+    private String timeType;
+
+    @Data
+    public static class ItemCodeItem {
+        /**
+         * 分项编码
+         */
+        private String code;
+
+        /**
+         * 分项名称
+         */
+        private String name;
+    }
+}

+ 46 - 0
service-ems/service-ems-biz/src/main/java/com/usky/ems/service/vo/ItemReportResponse.java

@@ -0,0 +1,46 @@
+package com.usky.ems.service.vo;
+
+import lombok.Data;
+
+import java.util.List;
+import java.util.Map;
+
+/**
+ * 分项报表响应VO
+ */
+@Data
+public class ItemReportResponse {
+
+    /**
+     * 数据列表
+     */
+    private List<Map<String, Object>> dataList;
+
+    /**
+     * 标题列定义列表
+     */
+    private List<TitleItem> titleList;
+
+    @Data
+    public static class TitleItem {
+        /**
+         * 列属性名
+         */
+        private String prop;
+
+        /**
+         * 是否固定列
+         */
+        private Boolean fixed;
+
+        /**
+         * 列标签
+         */
+        private String label;
+
+        /**
+         * 值(可选,用于序号等)
+         */
+        private Integer value;
+    }
+}

+ 63 - 0
service-ems/service-ems-biz/src/main/java/com/usky/ems/service/vo/SpaceReportRequest.java

@@ -0,0 +1,63 @@
+package com.usky.ems.service.vo;
+
+import lombok.Data;
+
+import java.util.List;
+
+/**
+ * 区域报表请求VO
+ */
+@Data
+public class SpaceReportRequest {
+
+    /**
+     * 区域列表
+     */
+    private List<SpaceItem> spaces;
+
+    /**
+     * 开始时间,格式:yyyy-MM-dd HH:mm:ss
+     */
+    private String startTime;
+
+    /**
+     * 结束时间,格式:yyyy-MM-dd HH:mm:ss
+     */
+    private String endTime;
+
+    /**
+     * 分项编码列表
+     */
+    private List<ItemCodeItem> itemCodes;
+
+    /**
+     * 日期类型:month-按月,year-按年
+     */
+    private String timeType;
+
+    @Data
+    public static class SpaceItem {
+        /**
+         * 区域ID
+         */
+        private Long id;
+
+        /**
+         * 区域名称
+         */
+        private String name;
+    }
+
+    @Data
+    public static class ItemCodeItem {
+        /**
+         * 分项编码
+         */
+        private String code;
+
+        /**
+         * 分项名称
+         */
+        private String name;
+    }
+}

+ 30 - 0
service-ems/service-ems-biz/src/main/java/com/usky/ems/service/vo/SpaceReportResponse.java

@@ -0,0 +1,30 @@
+package com.usky.ems.service.vo;
+
+import lombok.Data;
+
+import java.util.List;
+import java.util.Map;
+
+/**
+ * 区域报表响应VO
+ */
+@Data
+public class SpaceReportResponse {
+
+    /**
+     * 列定义列表
+     */
+    private List<EnergyReportResponse.ColumnItem> columnList;
+
+    /**
+     * 数据值列表(按区域/空间分组的二维数组)
+     * 外层 List 代表不同的区域
+     * 内层 List 包含该区域下所有设备的详细数据记录
+     */
+    private List<List<Map<String, Object>>> valueList;
+
+    /**
+     * 合计值
+     */
+    private Number total;
+}

Einige Dateien werden nicht angezeigt, da zu viele Dateien in diesem Diff geändert wurden.