Explorar el Código

能耗报表接口

fuyuchuan hace 1 semana
padre
commit
ce253da0c7

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

@@ -124,6 +124,15 @@ public class EmsReportController {
         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、分项编码、日期类型统计能耗
@@ -149,4 +158,13 @@ public class EmsReportController {
     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);
+    }
 }

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

@@ -53,4 +53,14 @@ public interface EmsReportService {
      * @return 区域报表响应
      */
     SpaceReportResponse getSpaceReport(SpaceReportRequest request);
+
+    /**
+     * 导出能源报表 Excel
+     */
+    void exportEnergyReport(EnergyReportRequest request, HttpServletResponse response);
+
+    /**
+     * 导出区域报表 Excel
+     */
+    void exportSpaceReport(SpaceReportRequest request, HttpServletResponse response);
 }

+ 631 - 167
service-ems/service-ems-biz/src/main/java/com/usky/ems/service/impl/EmsReportServiceImpl.java

@@ -14,6 +14,19 @@ 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;
@@ -21,6 +34,8 @@ 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;
@@ -40,6 +55,23 @@ 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
@@ -267,6 +299,26 @@ public class EmsReportServiceImpl implements EmsReportService {
         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);
+    }
+
     // ==================== 能源报表私有方法 ====================
 
     /**
@@ -281,6 +333,11 @@ public class EmsReportServiceImpl implements EmsReportService {
         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) {
@@ -311,14 +368,16 @@ public class EmsReportServiceImpl implements EmsReportService {
         historyRequestVO.setStartTime(request.getStartTime());
         historyRequestVO.setEndTime(request.getEndTime());
         historyRequestVO.setMetrics(request.getFuncList() != null && !request.getFuncList().isEmpty()
-                ? request.getFuncList().stream().map(EnergyReportRequest.FuncItem::getIdentifier).collect(Collectors.toList())
+                ? 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.warn("历史数据为空,设备uuid:{}", deviceUuids);
         log.info("历史数据接口返回:{}", listHistoryData);
 
         // 如果有数据,解析TSDB返回的结果
@@ -331,6 +390,8 @@ public class EmsReportServiceImpl implements EmsReportService {
         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;
@@ -345,110 +406,358 @@ public class EmsReportServiceImpl implements EmsReportService {
 
             // 如果没有功能点列表,为设备创建一个默认记录
             if (request.getFuncList() == null || request.getFuncList().isEmpty()) {
-                Map<String, String> timeData;
-                String total;
-
-                if (hasData && deviceUuid != null && tsdbDataMap.containsKey(deviceUuid)) {
-                    // 从TSDB数据中获取
-                    timeData = buildTimeDataFromTsdb(tsdbDataMap.get(deviceUuid), request.getTimeType(), startTime, endTime);
-                    total = calculateTotal(timeData);
+                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 {
-                    // 生成空数据结构
-                    timeData = generateEmptyTimeData(request.getTimeType(), startTime, endTime);
-                    total = "-";
+                    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));
                 }
-
-                EnergyReportResponse.ValueItem item = new EnergyReportResponse.ValueItem();
-                item.setDeviceId(deviceIdMap.getOrDefault(deviceUuid, null));
-                item.setDeviceName(device.getName());
-                item.setCommAddress(device.getCommAddress());
-                item.setIdentifier("-");
-                item.setTotal(total);
-                item.setTimeData(timeData);
-
-                deviceValueList.add(item);
             } else {
-                // 为每个功能点创建一条记录,添加到当前设备的子列表中
                 for (EnergyReportRequest.FuncItem func : request.getFuncList()) {
                     if (func == null || StringUtils.isEmpty(func.getIdentifier())) {
                         continue;
                     }
 
-                    Map<String, String> timeData;
-                    String total;
-
-                    // 如果有历史数据,从真实数据中查询;否则使用空值
-                    if (hasData && deviceUuid != null && tsdbDataMap.containsKey(deviceUuid)) {
-                        // 从TSDB数据中获取指定功能点的数据
-                        timeData = buildTimeDataFromTsdbForMetric(
-                                tsdbDataMap.get(deviceUuid),
-                                func.getIdentifier(),
-                                request.getTimeType(),
-                                startTime,
-                                endTime);
-                        total = calculateTotal(timeData);
+                    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 {
-                        // 生成空数据结构
-                        timeData = generateEmptyTimeData(request.getTimeType(), startTime, endTime);
-                        total = "0";
+                        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));
                     }
-
-                    EnergyReportResponse.ValueItem item = new EnergyReportResponse.ValueItem();
-                    item.setDeviceId(deviceIdMap.getOrDefault(deviceUuid, null));
-                    item.setDeviceName(device.getName());
-                    item.setCommAddress(device.getCommAddress());
-                    item.setIdentifier(func.getIdentifierName() != null ? func.getIdentifierName() : func.getIdentifier());
-                    item.setTotal(total);
-                    item.setTimeData(timeData);
-
-                    deviceValueList.add(item);
                 }
             }
 
-            // 将当前设备的所有功能点数据添加到外层列表
             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) {
-            String key = "_" + label.replace("时", "").replace("日", "").replace("月", "");
-            data.put(key, "0"); // 空值填充
+            data.put(toEnergyTimeDataKey(label), ENERGY_ZERO);
         }
-
         return data;
     }
 
     /**
-     * 计算合计值
+     * 计算合计:累计类为各时段差之和,瞬时类为查询区间内全量读数平均
      */
-    private String calculateTotal(Map<String, String> timeData) {
+    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 "0";
+            return ENERGY_NO_DATA;
         }
 
         BigDecimal sum = BigDecimal.ZERO;
-        boolean hasData = false;
+        boolean hasNumeric = false;
+        boolean allMissing = true;
         for (String value : timeData.values()) {
-            if (!"-".equals(value)) {
-                try {
-                    sum = sum.add(new BigDecimal(value));
-                    hasData = true;
-                } catch (NumberFormatException e) {
-                    // 忽略无效数据
-                }
+            if (ENERGY_NO_DATA.equals(value)) {
+                continue;
+            }
+            allMissing = false;
+            try {
+                sum = sum.add(new BigDecimal(value));
+                hasNumeric = true;
+            } catch (NumberFormatException e) {
+                // 忽略无效数据
             }
         }
 
-        return hasData ? sum.toString() : "-";
+        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数据解析方法 ====================
@@ -514,28 +823,16 @@ public class EmsReportServiceImpl implements EmsReportService {
     }
 
     /**
-     * 从TSDB数据中构建时间数据(所有功能点)
+     * 从TSDB数据中构建时间数据(取第一个功能点)
      */
     private Map<String, String> buildTimeDataFromTsdb(Map<String, List<Map<String, Object>>> metricDataMap,
                                                       String timeType, LocalDateTime startTime, LocalDateTime endTime) {
-        Map<String, String> timeData = new LinkedHashMap<>();
-
-        List<String> timeLabels = generateTimeLabels(timeType, startTime, endTime);
-        for (String label : timeLabels) {
-            String key = "_" + label.replace("时", "").replace("日", "").replace("月", "");
-
-            // 如果有数据,取第一个功能点的数据作为示例
-            if (!metricDataMap.isEmpty()) {
-                // 获取第一个功能点的数据
-                List<Map<String, Object>> firstMetricItems = metricDataMap.values().iterator().next();
-                String value = findValueByTimestamp(firstMetricItems, label, timeType, startTime, endTime);
-                timeData.put(key, value);
-            } else {
-                timeData.put(key, "-");
-            }
+        if (metricDataMap == null || metricDataMap.isEmpty()) {
+            return generateEmptyTimeData(timeType, startTime, endTime);
         }
-
-        return timeData;
+        Map.Entry<String, List<Map<String, Object>>> firstMetric = metricDataMap.entrySet().iterator().next();
+        return buildEnergyTimeDataForMetricItems(
+                firstMetric.getValue(), firstMetric.getKey(), timeType, startTime, endTime);
     }
 
     /**
@@ -546,90 +843,15 @@ public class EmsReportServiceImpl implements EmsReportService {
                                                                String timeType,
                                                                LocalDateTime startTime,
                                                                LocalDateTime endTime) {
-        Map<String, String> timeData = new LinkedHashMap<>();
-
-        List<String> timeLabels = generateTimeLabels(timeType, startTime, endTime);
-
-        // 获取指定功能点的数据
-        List<Map<String, Object>> metricItems = metricDataMap.get(metric);
-
-        for (String label : timeLabels) {
-            String key = "_" + label.replace("时", "").replace("日", "").replace("月", "");
-
-            if (metricItems != null && !metricItems.isEmpty()) {
-                String value = findValueByTimestamp(metricItems, label, timeType, startTime, endTime);
-                timeData.put(key, value);
-            } else {
-                timeData.put(key, "-");
-            }
-        }
-
-        return timeData;
-    }
-
-    /**
-     * 根据时间标签查找对应的值
-     * 按小时查询时,将小时范围划分为时间段,匹配对应时间点的数据
-     */
-    private String findValueByTimestamp(List<Map<String, Object>> metricItems,
-                                        String timeLabel,
-                                        String timeType,
-                                        LocalDateTime startTime,
-                                        LocalDateTime endTime) {
+        List<Map<String, Object>> metricItems = getMetricItemsIgnoreCase(metricDataMap, metric);
         if (metricItems == null || metricItems.isEmpty()) {
-            return "-";
-        }
-
-        // 解析时间标签,找到对应的时间点
-        // 例如:"1时" -> 需要从数据中找到该小时范围内的第一个数据点
-        try {
-            int hour = Integer.parseInt(timeLabel.replace("时", ""));
-            LocalDateTime targetTime = startTime.withHour(hour).withMinute(0).withSecond(0).withNano(0);
-            LocalDateTime nextTime = targetTime.plusHours(1);
-
-            // 查找该时间范围内的数据
-            for (Map<String, Object> item : metricItems) {
-                if (item.containsKey("timestamp") && item.containsKey("value")) {
-                    String timestampStr = (String) item.get("timestamp");
-                    if (StringUtils.hasText(timestampStr)) {
-                        LocalDateTime itemTime = parseDateTime(timestampStr);
-                        if (itemTime != null && !itemTime.isBefore(targetTime) && itemTime.isBefore(nextTime)) {
-                            // 找到匹配的数据,进行单位转换和精度处理
-                            String valueStr = (String) item.get("value");
-                            if (StringUtils.hasText(valueStr)) {
-                                try {
-                                    BigDecimal value = new BigDecimal(valueStr);
-                                    // 除以10000后保留两位小数
-                                    value = value.divide(new BigDecimal("10000")).setScale(2, java.math.RoundingMode.HALF_UP);
-                                    return value.toString();
-                                } catch (NumberFormatException e) {
-                                    return "-";
-                                }
-                            }
-                        }
-                    }
-                }
-            }
-        } catch (NumberFormatException e) {
-            // 如果解析失败,返回第一个数据点的值
-            if (!metricItems.isEmpty()) {
-                Map<String, Object> firstItem = metricItems.get(0);
-                if (firstItem.containsKey("value")) {
-                    String valueStr = (String) firstItem.get("value");
-                    if (StringUtils.hasText(valueStr)) {
-                        try {
-                            BigDecimal value = new BigDecimal(valueStr);
-                            value = value.divide(new BigDecimal("10000")).setScale(2, java.math.RoundingMode.HALF_UP);
-                            return value.toString();
-                        } catch (NumberFormatException ex) {
-                            return "-";
-                        }
-                    }
-                }
+            Map<String, String> empty = new LinkedHashMap<>();
+            for (TimeSegmentBound segment : buildReportTimeSegments(timeType, startTime, endTime)) {
+                empty.put(toEnergyTimeDataKey(segment.getLabel()), ENERGY_NO_DATA);
             }
+            return empty;
         }
-
-        return "-";
+        return buildEnergyTimeDataForMetricItems(metricItems, metric, timeType, startTime, endTime);
     }
 
     // ==================== 分项报表私有方法 ====================
@@ -967,8 +1189,8 @@ public class EmsReportServiceImpl implements EmsReportService {
     }
 
     private TreeMap<LocalDateTime, Double> extractSegmentReadings(TreeMap<LocalDateTime, Double> source,
-                                                                LocalDateTime startInclusive,
-                                                                LocalDateTime endExclusive) {
+                                                                  LocalDateTime startInclusive,
+                                                                  LocalDateTime endExclusive) {
         TreeMap<LocalDateTime, Double> result = new TreeMap<>();
         if (source == null || source.isEmpty() || !startInclusive.isBefore(endExclusive)) {
             return result;
@@ -1438,6 +1660,248 @@ public class EmsReportServiceImpl implements EmsReportService {
                 || 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() {