From d2ab6f7153e604bac7bc4ad58f27f368b65d8a1e Mon Sep 17 00:00:00 2001
From: yuan <123@>
Date: 星期二, 16 六月 2026 13:54:58 +0800
Subject: [PATCH] feat: 添加能耗数据综合分析功能,支持按天和周维度的趋势分析

---
 src/main/java/com/ruoyi/http/util/StatisticEleAggregateUtil.java       |    3 
 src/main/java/com/ruoyi/http/vo/StatisticEleAnalyticsVo.java           |   38 ++++
 src/main/java/com/ruoyi/http/vo/StatisticEleComparisonVo.java          |   28 +++
 src/main/java/com/ruoyi/http/controller/StatisticEleController.java    |   10 +
 src/main/java/com/ruoyi/http/service/StatisticEleService.java          |    3 
 src/main/java/com/ruoyi/http/util/StatisticEleAnalyticsUtil.java       |  288 ++++++++++++++++++++++++++++++++
 src/main/java/com/ruoyi/http/vo/StatisticEleSplitItemVo.java           |   18 ++
 src/main/java/com/ruoyi/http/service/impl/StatisticEleServiceImpl.java |   95 +++++++++-
 8 files changed, 473 insertions(+), 10 deletions(-)

diff --git a/src/main/java/com/ruoyi/http/controller/StatisticEleController.java b/src/main/java/com/ruoyi/http/controller/StatisticEleController.java
index 7994103..24e5169 100644
--- a/src/main/java/com/ruoyi/http/controller/StatisticEleController.java
+++ b/src/main/java/com/ruoyi/http/controller/StatisticEleController.java
@@ -44,6 +44,16 @@
         return AjaxResult.success(statisticEleService.getSummary(dimension, startTime, endTime));
     }
 
+    @GetMapping("/analytics")
+    @Operation(summary = "鑳借�楁暟鎹�-缁煎悎鍒嗘瀽")
+    public AjaxResult analytics(
+            @RequestParam(defaultValue = "day") String dimension,
+            @RequestParam String startTime,
+            @RequestParam String endTime,
+            @RequestParam(required = false) String trendGranularity) {
+        return AjaxResult.success(statisticEleService.getAnalytics(dimension, startTime, endTime, trendGranularity));
+    }
+
     @GetMapping("/yesterday")
     @Operation(summary = "鏄ㄦ棩鐢ㄧ數閲忔眹鎬�")
     public AjaxResult yesterday() {
diff --git a/src/main/java/com/ruoyi/http/service/StatisticEleService.java b/src/main/java/com/ruoyi/http/service/StatisticEleService.java
index 9b0cb00..0641f82 100644
--- a/src/main/java/com/ruoyi/http/service/StatisticEleService.java
+++ b/src/main/java/com/ruoyi/http/service/StatisticEleService.java
@@ -1,5 +1,6 @@
 package com.ruoyi.http.service;
 
+import com.ruoyi.http.vo.StatisticEleAnalyticsVo;
 import com.ruoyi.http.vo.StatisticEleRecordVo;
 import com.ruoyi.http.vo.StatisticEleSummaryVo;
 import com.ruoyi.http.vo.StatisticEleSyncStatusVo;
@@ -18,6 +19,8 @@
 
     StatisticEleSummaryVo getSummary(String dimension, String startTime, String endTime);
 
+    StatisticEleAnalyticsVo getAnalytics(String dimension, String startTime, String endTime, String trendGranularity);
+
     StatisticEleSummaryVo getYesterdaySummary();
 
     void exportRecords(String dimension, String startTime, String endTime, HttpServletResponse response);
diff --git a/src/main/java/com/ruoyi/http/service/impl/StatisticEleServiceImpl.java b/src/main/java/com/ruoyi/http/service/impl/StatisticEleServiceImpl.java
index b7c32a8..1d624d2 100644
--- a/src/main/java/com/ruoyi/http/service/impl/StatisticEleServiceImpl.java
+++ b/src/main/java/com/ruoyi/http/service/impl/StatisticEleServiceImpl.java
@@ -15,6 +15,9 @@
 import com.ruoyi.http.service.StatisticEleService;
 import com.ruoyi.http.util.StatisticEleAggregateUtil;
 import com.ruoyi.http.util.StatisticEleAggregateUtil.HourRange;
+import com.ruoyi.http.util.StatisticEleAnalyticsUtil;
+import com.ruoyi.http.util.StatisticEleAnalyticsUtil.DateBounds;
+import com.ruoyi.http.vo.StatisticEleAnalyticsVo;
 import com.ruoyi.http.vo.StatisticEleRecordVo;
 import com.ruoyi.http.vo.StatisticEleSummaryVo;
 import com.ruoyi.http.vo.StatisticEleSyncStatusVo;
@@ -31,16 +34,17 @@
 import java.util.Map;
 import java.util.Set;
 import java.util.function.Function;
-import java.util.stream.Collectors;
 
 @Service
 @Slf4j
 @RequiredArgsConstructor
 public class StatisticEleServiceImpl implements StatisticEleService {
 
-    private static final Set<String> STAT_DIMENSIONS = Set.of("day", "month", "quarter", "year");
+    private static final Set<String> STAT_DIMENSIONS = Set.of("day", "week", "month", "quarter", "year");
     private static final List<String> DATA_DIMENSIONS = List.of("hour", "manual");
     private static final DateTimeFormatter LOG_TIME_FMT = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
+
+    private static final DateTimeFormatter DAY_FMT = DateTimeFormatter.ofPattern("yyyyMMdd");
 
     private final TqdianbiaoConfig config;
     private final TqdianbiaoEleRecordMapper eleRecordMapper;
@@ -76,22 +80,91 @@
             throw new ServiceException("寮�濮嬫椂闂村拰缁撴潫鏃堕棿涓嶈兘涓虹┖");
         }
         if ("hour".equals(dimension)) {
-            List<StatisticEleRecordVo> detailRecords = queryHourRecords(startTime, endTime);
+            List<StatisticEleRecordVo> hourRecords = queryHourRecords(startTime, endTime);
             List<StatisticEleRecordVo> chartRecords = StatisticEleAggregateUtil.aggregateHourToBuckets(
-                    detailRecords, StatisticEleAggregateUtil.HOUR_TO_HOUR);
-            return buildSummary(detailRecords, chartRecords);
+                    hourRecords, StatisticEleAggregateUtil.HOUR_TO_HOUR);
+            return buildSummary(hourRecords, chartRecords, hourRecords);
         }
         if (!STAT_DIMENSIONS.contains(dimension)) {
-            throw new ServiceException("缁熻缁村害鏃犳晥锛屾敮鎸� hour/day/month/quarter/year");
+            throw new ServiceException("缁熻缁村害鏃犳晥锛屾敮鎸� hour/day/week/month/quarter/year");
         }
 
         if ("day".equals(dimension)) {
             return getDayDimensionSummary(startTime, endTime);
         }
 
+        HourRange range = StatisticEleAggregateUtil.toHourQueryRange(dimension, startTime, endTime);
+        List<StatisticEleRecordVo> hourRecords = queryHourRecords(range.startTime(), range.endTime());
         List<StatisticEleRecordVo> detailRecords = aggregateFromHour(dimension, startTime, endTime, true);
         List<StatisticEleRecordVo> chartRecords = aggregateFromHour(dimension, startTime, endTime, false);
-        return buildSummary(detailRecords, chartRecords);
+        return buildSummary(detailRecords, chartRecords, hourRecords);
+    }
+
+    @Override
+    public StatisticEleAnalyticsVo getAnalytics(String dimension, String startTime, String endTime, String trendGranularity) {
+        if (!StringUtils.hasText(startTime) || !StringUtils.hasText(endTime)) {
+            throw new ServiceException("寮�濮嬫椂闂村拰缁撴潫鏃堕棿涓嶈兘涓虹┖");
+        }
+        StatisticEleSummaryVo summary = getSummary(dimension, startTime, endTime);
+        HourRange range = StatisticEleAggregateUtil.toHourQueryRange(
+                normalizeAnalyticsDimension(dimension), startTime, endTime);
+        List<StatisticEleRecordVo> hourRecords = queryHourRecords(range.startTime(), range.endTime());
+        List<StatisticEleRecordVo> hourlyMerged = StatisticEleAggregateUtil.aggregateHourToBuckets(
+                hourRecords, StatisticEleAggregateUtil.HOUR_TO_HOUR);
+
+        boolean singleDay = "day".equals(dimension) && startTime.equals(endTime);
+        String trend = StringUtils.hasText(trendGranularity)
+                ? trendGranularity
+                : StatisticEleAnalyticsUtil.defaultTrendGranularity(dimension, singleDay);
+
+        StatisticEleAnalyticsVo analytics = copyToAnalytics(summary);
+        analytics.setLoadRate(StatisticEleAnalyticsUtil.calcLoadRate(
+                summary.getAvgConsumption(), summary.getMaxConsumption()));
+        analytics.setPeriodSplits(StatisticEleAnalyticsUtil.calcPeriodSplits(hourlyMerged));
+        analytics.setShiftSplits(StatisticEleAnalyticsUtil.calcShiftSplits(hourlyMerged));
+        analytics.setDayTypeSplits(StatisticEleAnalyticsUtil.calcDayTypeSplits(hourlyMerged));
+        analytics.setTrendGranularity(trend);
+        analytics.setTrendRecords(StatisticEleAggregateUtil.aggregateHourToBuckets(
+                hourRecords, StatisticEleAnalyticsUtil.trendBucketFn(trend)));
+
+        DateBounds bounds = StatisticEleAnalyticsUtil.resolveBounds(dimension, startTime, endTime);
+        Double chainTotal = queryTotalByDayBounds(StatisticEleAnalyticsUtil.shiftChain(bounds));
+        Double yoyTotal = queryTotalByDayBounds(StatisticEleAnalyticsUtil.shiftYoy(bounds));
+        analytics.setChainComparison(StatisticEleAnalyticsUtil.buildComparison(
+                "chain", "鐜瘮涓婃湡", summary.getTotalConsumption(), chainTotal));
+        analytics.setYoyComparison(StatisticEleAnalyticsUtil.buildComparison(
+                "yoy", "鍚屾瘮鍘诲勾鍚屾湡", summary.getTotalConsumption(), yoyTotal));
+        return analytics;
+    }
+
+    private String normalizeAnalyticsDimension(String dimension) {
+        return STAT_DIMENSIONS.contains(dimension) ? dimension : "day";
+    }
+
+    private Double queryTotalByDayBounds(DateBounds bounds) {
+        String start = bounds.start().format(DAY_FMT);
+        String end = bounds.end().format(DAY_FMT);
+        HourRange range = StatisticEleAggregateUtil.toHourQueryRange("day", start, end);
+        List<StatisticEleRecordVo> hourRecords = queryHourRecords(range.startTime(), range.endTime());
+        if (hourRecords.isEmpty()) {
+            return 0.0;
+        }
+        return StatisticEleAnalyticsUtil.calcCombinedMetrics(
+                hourRecords,
+                StatisticEleAggregateUtil.aggregateHourToBuckets(hourRecords, StatisticEleAggregateUtil.HOUR_TO_DAY)
+        ).getTotalConsumption();
+    }
+
+    private StatisticEleAnalyticsVo copyToAnalytics(StatisticEleSummaryVo summary) {
+        StatisticEleAnalyticsVo analytics = new StatisticEleAnalyticsVo();
+        analytics.setTotalConsumption(summary.getTotalConsumption());
+        analytics.setAvgConsumption(summary.getAvgConsumption());
+        analytics.setMaxConsumption(summary.getMaxConsumption());
+        analytics.setMinConsumption(summary.getMinConsumption());
+        analytics.setRecordCount(summary.getRecordCount());
+        analytics.setChartRecords(summary.getChartRecords());
+        analytics.setRecords(summary.getRecords());
+        return analytics;
     }
 
     /**
@@ -108,13 +181,15 @@
                 ? StatisticEleAggregateUtil.aggregateHourToBuckets(hourRecords, StatisticEleAggregateUtil.HOUR_TO_HOUR)
                 : StatisticEleAggregateUtil.aggregateHourToBuckets(hourRecords, StatisticEleAggregateUtil.HOUR_TO_DAY);
 
-        return buildSummary(detailRecords, chartRecords);
+        return buildSummary(detailRecords, chartRecords, hourRecords);
     }
 
     private StatisticEleSummaryVo buildSummary(
-            List<StatisticEleRecordVo> detailRecords, List<StatisticEleRecordVo> chartRecords) {
+            List<StatisticEleRecordVo> detailRecords,
+            List<StatisticEleRecordVo> chartRecords,
+            List<StatisticEleRecordVo> hourRecords) {
         StatisticEleAggregateUtil.StatisticEleSummaryMetrics metrics =
-                StatisticEleAggregateUtil.calcMetrics(chartRecords);
+                StatisticEleAnalyticsUtil.calcCombinedMetrics(hourRecords, chartRecords);
         StatisticEleSummaryVo summary = new StatisticEleSummaryVo();
         summary.setRecords(detailRecords);
         summary.setChartRecords(chartRecords);
diff --git a/src/main/java/com/ruoyi/http/util/StatisticEleAggregateUtil.java b/src/main/java/com/ruoyi/http/util/StatisticEleAggregateUtil.java
index dc3b133..4ff014f 100644
--- a/src/main/java/com/ruoyi/http/util/StatisticEleAggregateUtil.java
+++ b/src/main/java/com/ruoyi/http/util/StatisticEleAggregateUtil.java
@@ -34,6 +34,7 @@
         String monthKey = HOUR_TO_MONTH.apply(tk);
         return monthKey != null ? toQuarterKey(monthKey) : null;
     };
+    public static final Function<String, String> HOUR_TO_WEEK = StatisticEleAnalyticsUtil.HOUR_TO_WEEK;
 
     /**
      * 鎸夋椂闂存《姹囨�伙紙澶氱數琛ㄥ悎骞讹紝鐢ㄤ簬鍥捐〃锛�
@@ -178,6 +179,7 @@
         return switch (dimension) {
             case "hour" -> new HourRange(startTime, endTime);
             case "day" -> new HourRange(startTime + "00", endTime + "23");
+            case "week" -> new HourRange(startTime + "00", endTime + "23");
             case "month" -> new HourRange(startTime + "0100", endTime + lastDayOfMonth(endTime) + "23");
             case "year" -> new HourRange(startTime + "010100", endTime + "123123");
             case "quarter" -> new HourRange(
@@ -234,6 +236,7 @@
         return switch (dimension) {
             case "hour" -> HOUR_TO_HOUR;
             case "day" -> HOUR_TO_DAY;
+            case "week" -> HOUR_TO_WEEK;
             case "month" -> HOUR_TO_MONTH;
             case "quarter" -> HOUR_TO_QUARTER;
             case "year" -> HOUR_TO_YEAR;
diff --git a/src/main/java/com/ruoyi/http/util/StatisticEleAnalyticsUtil.java b/src/main/java/com/ruoyi/http/util/StatisticEleAnalyticsUtil.java
new file mode 100644
index 0000000..4a469ea
--- /dev/null
+++ b/src/main/java/com/ruoyi/http/util/StatisticEleAnalyticsUtil.java
@@ -0,0 +1,288 @@
+package com.ruoyi.http.util;
+
+import com.ruoyi.http.vo.StatisticEleComparisonVo;
+import com.ruoyi.http.vo.StatisticEleRecordVo;
+import com.ruoyi.http.vo.StatisticEleSplitItemVo;
+
+import java.math.BigDecimal;
+import java.math.RoundingMode;
+import java.time.DayOfWeek;
+import java.time.LocalDate;
+import java.time.YearMonth;
+import java.time.format.DateTimeFormatter;
+import java.time.temporal.ChronoUnit;
+import java.time.temporal.WeekFields;
+import java.util.ArrayList;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+import java.util.function.Function;
+
+/**
+ * 鑳借�楃患鍚堝垎鏋愯绠�
+ */
+public final class StatisticEleAnalyticsUtil {
+
+    private static final DateTimeFormatter DAY_FMT = DateTimeFormatter.ofPattern("yyyyMMdd");
+    private static final int SUMMARY_SCALE = 2;
+    /** 鐧界彮 08:00-19:59 */
+    private static final int DAY_SHIFT_START = 8;
+    private static final int DAY_SHIFT_END = 19;
+
+    private StatisticEleAnalyticsUtil() {
+    }
+
+    public static final Function<String, String> HOUR_TO_WEEK = tk -> {
+        if (tk == null || tk.length() < 8) {
+            return null;
+        }
+        LocalDate date = LocalDate.parse(tk.substring(0, 8), DAY_FMT);
+        WeekFields wf = WeekFields.of(Locale.CHINA);
+        int week = date.get(wf.weekOfWeekBasedYear());
+        int year = date.get(wf.weekBasedYear());
+        return String.format("%04dW%02d", year, week);
+    };
+
+    public static Function<String, String> trendBucketFn(String granularity) {
+        return switch (granularity) {
+            case "hour" -> StatisticEleAggregateUtil.HOUR_TO_HOUR;
+            case "week" -> HOUR_TO_WEEK;
+            case "month" -> StatisticEleAggregateUtil.HOUR_TO_MONTH;
+            case "year" -> StatisticEleAggregateUtil.HOUR_TO_YEAR;
+            default -> StatisticEleAggregateUtil.HOUR_TO_DAY;
+        };
+    }
+
+    /**
+     * 鍛ㄦ湡绱鏉ヨ嚜姹囨�绘《锛屾瀬鍊�/鍧囧�煎缁堟潵鑷皬鏃舵《銆�
+     */
+    public static StatisticEleAggregateUtil.StatisticEleSummaryMetrics calcCombinedMetrics(
+            List<StatisticEleRecordVo> hourRecords,
+            List<StatisticEleRecordVo> periodChartRecords) {
+        List<StatisticEleRecordVo> hourlyBuckets = StatisticEleAggregateUtil.aggregateHourToBuckets(
+                hourRecords, StatisticEleAggregateUtil.HOUR_TO_HOUR);
+        StatisticEleAggregateUtil.StatisticEleSummaryMetrics hourMetrics =
+                StatisticEleAggregateUtil.calcMetrics(hourlyBuckets);
+        StatisticEleAggregateUtil.StatisticEleSummaryMetrics periodMetrics =
+                StatisticEleAggregateUtil.calcMetrics(periodChartRecords);
+
+        StatisticEleAggregateUtil.StatisticEleSummaryMetrics result =
+                new StatisticEleAggregateUtil.StatisticEleSummaryMetrics();
+        result.setRecordCount(periodMetrics.getRecordCount());
+        result.setTotalConsumption(hourMetrics.getTotalConsumption());
+        result.setAvgConsumption(hourMetrics.getAvgConsumption());
+        result.setMaxConsumption(hourMetrics.getMaxConsumption());
+        result.setMinConsumption(hourMetrics.getMinConsumption());
+        return result;
+    }
+
+    public static double calcLoadRate(Double avgHourly, Double maxHourly) {
+        if (avgHourly == null || maxHourly == null || maxHourly <= 0) {
+            return 0.0;
+        }
+        return roundSummary(BigDecimal.valueOf(avgHourly)
+                .divide(BigDecimal.valueOf(maxHourly), SUMMARY_SCALE + 2, RoundingMode.HALF_UP)
+                .multiply(BigDecimal.valueOf(100)));
+    }
+
+    public static List<StatisticEleSplitItemVo> calcPeriodSplits(List<StatisticEleRecordVo> hourRecords) {
+        double sharp = sumField(hourRecords, StatisticEleRecordVo::getSharpConsumption);
+        double peak = sumField(hourRecords, StatisticEleRecordVo::getPeakConsumption);
+        double flat = sumField(hourRecords, StatisticEleRecordVo::getFlatConsumption);
+        double valley = sumField(hourRecords, StatisticEleRecordVo::getValleyConsumption);
+        double total = sharp + peak + flat + valley;
+        if (total <= 0) {
+            total = sumField(hourRecords, StatisticEleRecordVo::getTotalConsumption);
+        }
+        List<StatisticEleSplitItemVo> items = new ArrayList<>();
+        addSplitItem(items, "灏�", sharp, total);
+        addSplitItem(items, "宄�", peak, total);
+        addSplitItem(items, "骞�", flat, total);
+        addSplitItem(items, "璋�", valley, total);
+        return items.stream().filter(i -> i.getConsumption() != null && i.getConsumption() > 0).toList();
+    }
+
+    public static List<StatisticEleSplitItemVo> calcShiftSplits(List<StatisticEleRecordVo> hourRecords) {
+        Map<String, Double> map = new LinkedHashMap<>();
+        map.put("鐧界彮", 0.0);
+        map.put("澶滅彮", 0.0);
+        for (StatisticEleRecordVo record : hourRecords) {
+            int hour = parseHour(record.getTimeKey());
+            if (hour < 0) {
+                continue;
+            }
+            double val = safe(record.getTotalConsumption());
+            if (hour >= DAY_SHIFT_START && hour <= DAY_SHIFT_END) {
+                map.merge("鐧界彮", val, Double::sum);
+            } else {
+                map.merge("澶滅彮", val, Double::sum);
+            }
+        }
+        return toSplitItems(map);
+    }
+
+    public static List<StatisticEleSplitItemVo> calcDayTypeSplits(List<StatisticEleRecordVo> hourRecords) {
+        Map<String, Double> map = new LinkedHashMap<>();
+        map.put("宸ヤ綔鏃�", 0.0);
+        map.put("浼戞伅鏃�", 0.0);
+        for (StatisticEleRecordVo record : hourRecords) {
+            LocalDate date = parseDate(record.getTimeKey());
+            if (date == null) {
+                continue;
+            }
+            double val = safe(record.getTotalConsumption());
+            DayOfWeek dow = date.getDayOfWeek();
+            if (dow == DayOfWeek.SATURDAY || dow == DayOfWeek.SUNDAY) {
+                map.merge("浼戞伅鏃�", val, Double::sum);
+            } else {
+                map.merge("宸ヤ綔鏃�", val, Double::sum);
+            }
+        }
+        return toSplitItems(map);
+    }
+
+    public static StatisticEleComparisonVo buildComparison(
+            String type, String label, Double currentTotal, Double compareTotal) {
+        StatisticEleComparisonVo vo = new StatisticEleComparisonVo();
+        vo.setType(type);
+        vo.setLabel(label);
+        vo.setCurrentTotal(round(currentTotal));
+        vo.setCompareTotal(round(compareTotal));
+        double delta = round(safe(currentTotal) - safe(compareTotal));
+        vo.setDelta(delta);
+        if (compareTotal != null && compareTotal > 0) {
+            vo.setChangeRate(roundSummary(BigDecimal.valueOf(delta)
+                    .divide(BigDecimal.valueOf(compareTotal), SUMMARY_SCALE + 2, RoundingMode.HALF_UP)
+                    .multiply(BigDecimal.valueOf(100))));
+        } else {
+            vo.setChangeRate(currentTotal != null && currentTotal > 0 ? 100.0 : 0.0);
+        }
+        return vo;
+    }
+
+    public record DateBounds(LocalDate start, LocalDate end) {}
+
+    public static DateBounds resolveBounds(String dimension, String startTime, String endTime) {
+        LocalDate start = resolveStartDate(dimension, startTime);
+        LocalDate end = resolveEndDate(dimension, endTime);
+        if (start.isAfter(end)) {
+            LocalDate tmp = start;
+            start = end;
+            end = tmp;
+        }
+        return new DateBounds(start, end);
+    }
+
+    public static DateBounds shiftChain(DateBounds bounds) {
+        long days = ChronoUnit.DAYS.between(bounds.start(), bounds.end()) + 1;
+        return new DateBounds(bounds.start().minusDays(days), bounds.start().minusDays(1));
+    }
+
+    public static DateBounds shiftYoy(DateBounds bounds) {
+        return new DateBounds(bounds.start().minusYears(1), bounds.end().minusYears(1));
+    }
+
+    public static String defaultTrendGranularity(String dimension, boolean singleDay) {
+        if (singleDay) {
+            return "hour";
+        }
+        return switch (dimension) {
+            case "week", "day" -> "day";
+            case "month", "quarter" -> "week";
+            case "year" -> "month";
+            default -> "day";
+        };
+    }
+
+    private static LocalDate resolveStartDate(String dimension, String startTime) {
+        return switch (dimension) {
+            case "year" -> LocalDate.of(Integer.parseInt(startTime), 1, 1);
+            case "month" -> YearMonth.parse(startTime, DateTimeFormatter.ofPattern("yyyyMM")).atDay(1);
+            default -> LocalDate.parse(normalizeDayKey(startTime), DAY_FMT);
+        };
+    }
+
+    private static LocalDate resolveEndDate(String dimension, String endTime) {
+        return switch (dimension) {
+            case "year" -> LocalDate.of(Integer.parseInt(endTime), 12, 31);
+            case "month" -> YearMonth.parse(endTime, DateTimeFormatter.ofPattern("yyyyMM")).atEndOfMonth();
+            default -> LocalDate.parse(normalizeDayKey(endTime), DAY_FMT);
+        };
+    }
+
+    private static String normalizeDayKey(String timeKey) {
+        if (timeKey == null) {
+            return LocalDate.now().format(DAY_FMT);
+        }
+        if (timeKey.length() == 4) {
+            return timeKey + "0101";
+        }
+        if (timeKey.length() == 6) {
+            return timeKey + "01";
+        }
+        return timeKey.length() >= 8 ? timeKey.substring(0, 8) : timeKey;
+    }
+
+    private static List<StatisticEleSplitItemVo> toSplitItems(Map<String, Double> map) {
+        double total = map.values().stream().mapToDouble(v -> v).sum();
+        List<StatisticEleSplitItemVo> items = new ArrayList<>();
+        map.forEach((name, val) -> addSplitItem(items, name, val, total));
+        return items;
+    }
+
+    private static void addSplitItem(List<StatisticEleSplitItemVo> items, String name, double val, double total) {
+        if (val <= 0) {
+            return;
+        }
+        StatisticEleSplitItemVo item = new StatisticEleSplitItemVo();
+        item.setName(name);
+        item.setConsumption(round(val));
+        item.setRatio(total > 0 ? roundSummary(BigDecimal.valueOf(val)
+                .divide(BigDecimal.valueOf(total), SUMMARY_SCALE + 2, RoundingMode.HALF_UP)
+                .multiply(BigDecimal.valueOf(100))) : 0.0);
+        items.add(item);
+    }
+
+    private static double sumField(List<StatisticEleRecordVo> records,
+                                   Function<StatisticEleRecordVo, Double> getter) {
+        return records.stream().map(getter).mapToDouble(StatisticEleAnalyticsUtil::safe).sum();
+    }
+
+    private static int parseHour(String timeKey) {
+        if (timeKey == null || timeKey.length() < 10) {
+            return -1;
+        }
+        try {
+            return Integer.parseInt(timeKey.substring(8, 10));
+        } catch (NumberFormatException e) {
+            return -1;
+        }
+    }
+
+    private static LocalDate parseDate(String timeKey) {
+        if (timeKey == null || timeKey.length() < 8) {
+            return null;
+        }
+        try {
+            return LocalDate.parse(timeKey.substring(0, 8), DAY_FMT);
+        } catch (Exception e) {
+            return null;
+        }
+    }
+
+    private static double safe(Double v) {
+        return v == null ? 0.0 : v;
+    }
+
+    private static double round(Double v) {
+        if (v == null) {
+            return 0.0;
+        }
+        return roundSummary(BigDecimal.valueOf(v));
+    }
+
+    private static double roundSummary(BigDecimal value) {
+        return value.setScale(SUMMARY_SCALE, RoundingMode.HALF_UP).doubleValue();
+    }
+}
diff --git a/src/main/java/com/ruoyi/http/vo/StatisticEleAnalyticsVo.java b/src/main/java/com/ruoyi/http/vo/StatisticEleAnalyticsVo.java
new file mode 100644
index 0000000..6170520
--- /dev/null
+++ b/src/main/java/com/ruoyi/http/vo/StatisticEleAnalyticsVo.java
@@ -0,0 +1,38 @@
+package com.ruoyi.http.vo;
+
+import lombok.Data;
+import lombok.EqualsAndHashCode;
+
+import java.util.List;
+
+/**
+ * 鑳借�楃患鍚堝垎鏋愮粨鏋�
+ */
+@Data
+@EqualsAndHashCode(callSuper = true)
+public class StatisticEleAnalyticsVo extends StatisticEleSummaryVo {
+
+    /** 璐熻嵎鐜� = 灏忔椂骞冲潎 梅 灏忔椂鏈�澶� */
+    private Double loadRate;
+
+    /** 宄板钩璋峰皷鎷嗗垎 */
+    private List<StatisticEleSplitItemVo> periodSplits;
+
+    /** 鐧界彮/澶滅彮鎷嗗垎 */
+    private List<StatisticEleSplitItemVo> shiftSplits;
+
+    /** 宸ヤ綔鏃�/浼戞伅鏃ユ媶鍒� */
+    private List<StatisticEleSplitItemVo> dayTypeSplits;
+
+    /** 鐜瘮 */
+    private StatisticEleComparisonVo chainComparison;
+
+    /** 鍚屾瘮 */
+    private StatisticEleComparisonVo yoyComparison;
+
+    /** 瓒嬪娍鍥剧矑搴� */
+    private String trendGranularity;
+
+    /** 瓒嬪娍鍥炬暟鎹紙鎸� trendGranularity 鑱氬悎锛� */
+    private List<StatisticEleRecordVo> trendRecords;
+}
diff --git a/src/main/java/com/ruoyi/http/vo/StatisticEleComparisonVo.java b/src/main/java/com/ruoyi/http/vo/StatisticEleComparisonVo.java
new file mode 100644
index 0000000..f9173db
--- /dev/null
+++ b/src/main/java/com/ruoyi/http/vo/StatisticEleComparisonVo.java
@@ -0,0 +1,28 @@
+package com.ruoyi.http.vo;
+
+import lombok.Data;
+
+/**
+ * 鍚屾瘮/鐜瘮瀵规瘮
+ */
+@Data
+public class StatisticEleComparisonVo {
+
+    /** 瀵规瘮绫诲瀷锛歝hain-鐜瘮, yoy-鍚屾瘮 */
+    private String type;
+
+    /** 瀵规瘮鏈熸爣绛撅紝濡傘�屼笂鏈熴�嶃�屽幓骞村悓鏈熴�� */
+    private String label;
+
+    /** 瀵规瘮鏈熸�荤敤鐢甸噺(kWh) */
+    private Double compareTotal;
+
+    /** 鏈湡鎬荤敤鐢甸噺(kWh) */
+    private Double currentTotal;
+
+    /** 宸��(kWh)锛屾湰鏈�-瀵规瘮鏈� */
+    private Double delta;
+
+    /** 鍙樺寲鐜�(%) */
+    private Double changeRate;
+}
diff --git a/src/main/java/com/ruoyi/http/vo/StatisticEleSplitItemVo.java b/src/main/java/com/ruoyi/http/vo/StatisticEleSplitItemVo.java
new file mode 100644
index 0000000..8820b05
--- /dev/null
+++ b/src/main/java/com/ruoyi/http/vo/StatisticEleSplitItemVo.java
@@ -0,0 +1,18 @@
+package com.ruoyi.http.vo;
+
+import lombok.Data;
+
+/**
+ * 鐢ㄧ數閲忔媶鍒嗛」锛堟椂娈�/鐝/鏃ョ被鍨嬬瓑锛�
+ */
+@Data
+public class StatisticEleSplitItemVo {
+
+    private String name;
+
+    /** 鐢ㄧ數閲�(kWh) */
+    private Double consumption;
+
+    /** 鍗犳瘮(%) */
+    private Double ratio;
+}

--
Gitblit v1.9.3