Pārlūkot izejas kodu

Merge branch 'feature/service-vpp-20260701' of uskycloud/usky-modules into master

hanzhengyi 4 dienas atpakaļ
vecāks
revīzija
94415baff7
16 mainītis faili ar 1273 papildinājumiem un 17 dzēšanām
  1. 35 0
      service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/constant/VppTsdbConstants.java
  2. 56 0
      service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/controller/web/CapabilityEvalController.java
  3. 1 1
      service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/service/VppAliyunSmsService.java
  4. 34 0
      service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/service/VppCapabilityEvalService.java
  5. 31 0
      service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/service/VppTsdbQueryService.java
  6. 353 0
      service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/service/impl/VppCapabilityEvalServiceImpl.java
  7. 170 0
      service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/service/impl/VppTsdbQueryServiceImpl.java
  8. 21 0
      service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/service/vo/CapabilityCategoryStatVO.java
  9. 27 0
      service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/service/vo/CapabilityEvalSummaryVO.java
  10. 31 0
      service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/service/vo/CapabilityHistoryVO.java
  11. 20 0
      service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/service/vo/CapabilityLoadCurveVO.java
  12. 19 0
      service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/service/vo/CapabilityLoadPointVO.java
  13. 364 0
      service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/util/VppCapabilityEvalHelper.java
  14. 16 15
      service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/util/VppSmsTemplateHelper.java
  15. 1 1
      service-vpp/service-vpp-biz/src/main/resources/bootstrap.yml
  16. 94 0
      service-vpp/service-vpp-biz/src/main/resources/logback.xml

+ 35 - 0
service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/constant/VppTsdbConstants.java

@@ -0,0 +1,35 @@
+package com.usky.vpp.constant;
+
+import java.util.Arrays;
+import java.util.List;
+
+/**
+ * VPP TDengine 指标常量(与 device_metrics 超级表及 EMS 存量表字段兼容)
+ */
+public final class VppTsdbConstants {
+
+    private VppTsdbConstants() {
+    }
+
+    /** 有功功率 / 实际负荷(优先匹配顺序) */
+    public static final List<String> ACTIVE_POWER_METRICS = Arrays.asList(
+            "active_power",
+            "totalActivePower",
+            "totalactivepower",
+            "activepowera"
+    );
+
+    /** 实时上调能力 */
+    public static final List<String> UP_CAPACITY_METRICS = Arrays.asList(
+            "up_capacity_kw",
+            "upCapacityKw"
+    );
+
+    /** 实时下调能力 */
+    public static final List<String> DOWN_CAPACITY_METRICS = Arrays.asList(
+            "down_capacity_kw",
+            "downCapacityKw"
+    );
+
+    public static final String TIME_FORMAT = "yyyy-MM-dd HH:mm:ss";
+}

+ 56 - 0
service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/controller/web/CapabilityEvalController.java

@@ -0,0 +1,56 @@
+package com.usky.vpp.controller.web;
+
+import com.usky.common.core.bean.ApiResult;
+import com.usky.common.core.bean.CommonPage;
+import com.usky.vpp.service.VppCapabilityEvalService;
+import com.usky.vpp.service.vo.CapabilityEvalSummaryVO;
+import com.usky.vpp.service.vo.CapabilityHistoryVO;
+import com.usky.vpp.service.vo.CapabilityLoadCurveVO;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RequestParam;
+import org.springframework.web.bind.annotation.RestController;
+
+import java.util.Map;
+
+/**
+ * 可调能力评估接口
+ * <p>网关前缀: /prod-api/service-vpp/capability-eval</p>
+ */
+@RestController
+@RequestMapping("/capability-eval")
+public class CapabilityEvalController {
+
+    @Autowired
+    private VppCapabilityEvalService vppCapabilityEvalService;
+
+    /**
+     * 顶部汇总统计
+     * <p>支持按 siteId 筛选;返回总资源/负荷类/储能类/发电类户数与容量,及最大上下调能力。</p>
+     */
+    @GetMapping("/summary")
+    public ApiResult<CapabilityEvalSummaryVO> summary(@RequestParam(value = "siteId", required = false) Long siteId) {
+        return ApiResult.success(vppCapabilityEvalService.getSummary(siteId));
+    }
+
+    /**
+     * 可调能力曲线(基线负荷 vs 实际负荷)
+     * <p>默认统计当天,每 10 分钟一个点(00:00 ~ 23:50)。</p>
+     */
+    @GetMapping("/load-curve")
+    public ApiResult<CapabilityLoadCurveVO> loadCurve(@RequestParam(value = "siteId", required = false) Long siteId,
+                                                      @RequestParam(value = "date", required = false) String date) {
+        return ApiResult.success(vppCapabilityEvalService.getLoadCurve(siteId, date));
+    }
+
+    /**
+     * 历史响应记录(分页)
+     * <p>按站点下资源参与的需求响应事件倒序展示。</p>
+     */
+    @GetMapping("/history")
+    public ApiResult<CommonPage<CapabilityHistoryVO>> history(@RequestParam(value = "siteId", required = false) Long siteId,
+                                                            @RequestParam(required = false) Map<String, Object> params) {
+        return ApiResult.success(vppCapabilityEvalService.pageHistory(siteId, params));
+    }
+}

+ 1 - 1
service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/service/VppAliyunSmsService.java

@@ -10,7 +10,7 @@ public interface VppAliyunSmsService {
      *
      * @param phone        手机号
      * @param templateCode 模板 CODE
-     * @param templateParam JSON 模板变量,如 {"name":"张三","event":"EVE_001"}
+     * @param templateParam JSON 模板变量,如 {"meet":"夏季削峰","time":"2026-07-08 14:00:00","room":"某某公司"}
      */
     void sendTemplateSms(String phone, String templateCode, String templateParam);
 }

+ 34 - 0
service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/service/VppCapabilityEvalService.java

@@ -0,0 +1,34 @@
+package com.usky.vpp.service;
+
+import com.usky.common.core.bean.CommonPage;
+import com.usky.vpp.service.vo.CapabilityEvalSummaryVO;
+import com.usky.vpp.service.vo.CapabilityHistoryVO;
+import com.usky.vpp.service.vo.CapabilityLoadCurveVO;
+
+import java.util.Map;
+
+/**
+ * 可调能力评估业务接口
+ */
+public interface VppCapabilityEvalService {
+
+    /**
+     * 顶部汇总统计(总资源/负荷类/储能类/发电类 + 最大上下调能力)
+     *
+     * @param siteId 站点 ID,为空时统计全部站点
+     */
+    CapabilityEvalSummaryVO getSummary(Long siteId);
+
+    /**
+     * 可调能力曲线(基线负荷 vs 实际负荷)
+     *
+     * @param siteId 站点 ID,为空时统计全部站点
+     * @param date   统计日期 yyyy-MM-dd,默认当天
+     */
+    CapabilityLoadCurveVO getLoadCurve(Long siteId, String date);
+
+    /**
+     * 历史响应记录(分页)
+     */
+    CommonPage<CapabilityHistoryVO> pageHistory(Long siteId, Map<String, Object> params);
+}

+ 31 - 0
service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/service/VppTsdbQueryService.java

@@ -0,0 +1,31 @@
+package com.usky.vpp.service;
+
+import java.math.BigDecimal;
+import java.time.LocalDateTime;
+import java.util.List;
+import java.util.Map;
+import java.util.TreeMap;
+
+/**
+ * VPP 时序数据(TDengine)查询服务
+ */
+public interface VppTsdbQueryService {
+
+    /**
+     * 批量查询设备指标历史曲线
+     *
+     * @return deviceUuid -> metric -> time -> value
+     */
+    Map<String, Map<String, TreeMap<LocalDateTime, BigDecimal>>> queryDeviceMetricHistory(
+            List<String> deviceUuids,
+            LocalDateTime startTime,
+            LocalDateTime endTime,
+            List<String> metrics);
+
+    /**
+     * 批量查询设备最新指标值
+     *
+     * @return deviceUuid -> metric -> value
+     */
+    Map<String, Map<String, BigDecimal>> queryLatestMetrics(List<String> deviceUuids, List<String> metrics);
+}

+ 353 - 0
service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/service/impl/VppCapabilityEvalServiceImpl.java

@@ -0,0 +1,353 @@
+package com.usky.vpp.service.impl;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.usky.common.core.bean.CommonPage;
+import com.usky.common.core.exception.BusinessException;
+import com.usky.vpp.domain.VppDevice;
+import com.usky.vpp.domain.VppDrEvaluation;
+import com.usky.vpp.domain.VppDrEvent;
+import com.usky.vpp.domain.VppDrParticipation;
+import com.usky.vpp.domain.VppResourcePoint;
+import com.usky.vpp.domain.VppSite;
+import com.usky.vpp.mapper.VppDeviceMapper;
+import com.usky.vpp.mapper.VppDrEvaluationMapper;
+import com.usky.vpp.mapper.VppDrEventMapper;
+import com.usky.vpp.mapper.VppDrParticipationMapper;
+import com.usky.vpp.mapper.VppResourcePointMapper;
+import com.usky.vpp.mapper.VppSiteMapper;
+import com.usky.vpp.constant.VppTsdbConstants;
+import com.usky.vpp.service.VppCapabilityEvalService;
+import com.usky.vpp.service.VppTsdbQueryService;
+import com.usky.vpp.service.vo.CapabilityCategoryStatVO;
+import com.usky.vpp.service.vo.CapabilityEvalSummaryVO;
+import com.usky.vpp.service.vo.CapabilityHistoryVO;
+import com.usky.vpp.service.vo.CapabilityLoadCurveVO;
+import com.usky.vpp.service.vo.CapabilityLoadPointVO;
+import com.usky.vpp.util.VppAuditHelper;
+import com.usky.vpp.util.VppCapabilityEvalHelper;
+import com.usky.vpp.util.VppPageHelper;
+import com.usky.vpp.util.VppSiteResourceHelper;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.stereotype.Service;
+import org.springframework.util.StringUtils;
+
+import java.math.BigDecimal;
+import java.time.LocalDate;
+import java.time.LocalDateTime;
+import java.time.format.DateTimeFormatter;
+import java.time.format.DateTimeParseException;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.Comparator;
+import java.util.HashSet;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import java.util.Set;
+import java.util.TreeMap;
+import java.util.stream.Collectors;
+
+@Service
+public class VppCapabilityEvalServiceImpl implements VppCapabilityEvalService {
+
+    private static final int PARTICIPATE_STATUS_ACCEPT = 1;
+    private static final int BASELINE_DAYS_AGO = 7;
+    private static final DateTimeFormatter DATE_FMT = DateTimeFormatter.ofPattern("yyyy-MM-dd");
+
+    @Autowired
+    private VppResourcePointMapper resourcePointMapper;
+    @Autowired
+    private VppSiteMapper siteMapper;
+    @Autowired
+    private VppDeviceMapper deviceMapper;
+    @Autowired
+    private VppDrEventMapper eventMapper;
+    @Autowired
+    private VppDrParticipationMapper participationMapper;
+    @Autowired
+    private VppDrEvaluationMapper evaluationMapper;
+    @Autowired
+    private VppSiteResourceHelper siteResourceHelper;
+    @Autowired
+    private VppTsdbQueryService vppTsdbQueryService;
+
+    @Override
+    public CapabilityEvalSummaryVO getSummary(Long siteId) {
+        VppSite site = resolveSite(siteId);
+        List<VppResourcePoint> resources = listResources(siteId);
+        Map<Long, VppSite> siteMap = siteResourceHelper.loadSiteMap(resources);
+        List<VppDevice> devices = loadDevices(resources);
+
+        CapabilityEvalSummaryVO summary = new CapabilityEvalSummaryVO();
+        summary.setSiteId(siteId);
+        summary.setSiteName(site != null ? site.getSiteName() : "全部站点");
+        summary.setTotal(buildCategoryStat(VppCapabilityEvalHelper.CATEGORY_TOTAL, resources, siteMap));
+        summary.setLoad(buildCategoryStat(VppCapabilityEvalHelper.CATEGORY_LOAD, resources, siteMap));
+        summary.setStorage(buildCategoryStat(VppCapabilityEvalHelper.CATEGORY_STORAGE, resources, siteMap));
+        summary.setGeneration(buildCategoryStat(VppCapabilityEvalHelper.CATEGORY_GENERATION, resources, siteMap));
+
+        BigDecimal maxUp = BigDecimal.ZERO;
+        BigDecimal maxDown = BigDecimal.ZERO;
+        for (VppResourcePoint resource : resources) {
+            if (resource.getIsControl() == null || resource.getIsControl() != 1) {
+                continue;
+            }
+            maxUp = maxUp.add(resource.getMaxUpKw() != null ? resource.getMaxUpKw() : BigDecimal.ZERO);
+            maxDown = maxDown.add(resource.getMinDownKw() != null ? resource.getMinDownKw() : BigDecimal.ZERO);
+        }
+        applyRealtimeAdjustCapacity(resources, devices, summary, maxUp, maxDown);
+        return summary;
+    }
+
+    private void applyRealtimeAdjustCapacity(List<VppResourcePoint> resources,
+                                             List<VppDevice> devices,
+                                             CapabilityEvalSummaryVO summary,
+                                             BigDecimal fallbackUp,
+                                             BigDecimal fallbackDown) {
+        List<String> deviceUuids = resolveDeviceUuids(resources, devices);
+        if (deviceUuids.isEmpty()) {
+            summary.setMaxUpKw(fallbackUp);
+            summary.setMaxDownKw(fallbackDown);
+            return;
+        }
+
+        List<String> metrics = new ArrayList<>();
+        metrics.addAll(VppTsdbConstants.UP_CAPACITY_METRICS);
+        metrics.addAll(VppTsdbConstants.DOWN_CAPACITY_METRICS);
+        Map<String, Map<String, BigDecimal>> latest = vppTsdbQueryService.queryLatestMetrics(deviceUuids, metrics);
+
+        BigDecimal realtimeUp = VppCapabilityEvalHelper.sumLatestMetric(latest, VppTsdbConstants.UP_CAPACITY_METRICS);
+        BigDecimal realtimeDown = VppCapabilityEvalHelper.sumLatestMetric(latest, VppTsdbConstants.DOWN_CAPACITY_METRICS);
+        summary.setMaxUpKw(realtimeUp.compareTo(BigDecimal.ZERO) > 0 ? realtimeUp : fallbackUp);
+        summary.setMaxDownKw(realtimeDown.compareTo(BigDecimal.ZERO) > 0 ? realtimeDown : fallbackDown);
+    }
+
+    @Override
+    public CapabilityLoadCurveVO getLoadCurve(Long siteId, String date) {
+        VppSite site = resolveSite(siteId);
+        LocalDate statDate = parseDate(date);
+        List<VppResourcePoint> resources = listResources(siteId);
+        List<VppDevice> devices = loadDevices(resources);
+
+        CapabilityLoadCurveVO curve = new CapabilityLoadCurveVO();
+        curve.setSiteId(siteId);
+        curve.setSiteName(site != null ? site.getSiteName() : "全部站点");
+        curve.setDate(statDate.format(DATE_FMT));
+        LoadCurveBuildResult buildResult = buildLoadCurvePoints(resources, devices, siteId, statDate);
+        curve.setPoints(buildResult.points);
+        curve.setDataSource(buildResult.dataSource);
+        return curve;
+    }
+
+    private static class LoadCurveBuildResult {
+        private final List<CapabilityLoadPointVO> points;
+        private final String dataSource;
+
+        private LoadCurveBuildResult(List<CapabilityLoadPointVO> points, String dataSource) {
+            this.points = points;
+            this.dataSource = dataSource;
+        }
+    }
+
+    private LoadCurveBuildResult buildLoadCurvePoints(List<VppResourcePoint> resources,
+                                                      List<VppDevice> devices,
+                                                      Long siteId,
+                                                      LocalDate statDate) {
+        List<String> deviceUuids = resolveDeviceUuids(resources, devices);
+        if (!deviceUuids.isEmpty()) {
+            LocalDateTime dayStart = statDate.atStartOfDay();
+            LocalDateTime dayEnd = statDate.atTime(23, 59, 59);
+            LocalDate baselineDate = statDate.minusDays(BASELINE_DAYS_AGO);
+            LocalDateTime baselineStart = baselineDate.atStartOfDay();
+            LocalDateTime baselineEnd = baselineDate.atTime(23, 59, 59);
+
+            List<String> powerMetrics = new ArrayList<>(VppTsdbConstants.ACTIVE_POWER_METRICS);
+            Map<String, Map<String, TreeMap<LocalDateTime, BigDecimal>>> actualData =
+                    vppTsdbQueryService.queryDeviceMetricHistory(deviceUuids, dayStart, dayEnd, powerMetrics);
+            if (VppCapabilityEvalHelper.hasTsdbPowerData(actualData, powerMetrics)) {
+                Map<String, Map<String, TreeMap<LocalDateTime, BigDecimal>>> baselineData =
+                        vppTsdbQueryService.queryDeviceMetricHistory(deviceUuids, baselineStart, baselineEnd, powerMetrics);
+                return new LoadCurveBuildResult(
+                        VppCapabilityEvalHelper.buildLoadCurveFromTsdb(actualData, baselineData, statDate, powerMetrics),
+                        "tsdb");
+            }
+        }
+        return new LoadCurveBuildResult(
+                VppCapabilityEvalHelper.buildLoadCurve(resources, devices, siteId, statDate),
+                "mock");
+    }
+
+    @Override
+    public CommonPage<CapabilityHistoryVO> pageHistory(Long siteId, Map<String, Object> params) {
+        resolveSite(siteId);
+        Set<Long> resourceIds = listResources(siteId).stream()
+                .map(VppResourcePoint::getId)
+                .collect(Collectors.toSet());
+        if (resourceIds.isEmpty()) {
+            return emptyPage(params);
+        }
+
+        List<VppDrParticipation> participations = participationMapper.selectList(
+                new LambdaQueryWrapper<VppDrParticipation>()
+                        .in(VppDrParticipation::getResourceId, resourceIds)
+                        .eq(VppDrParticipation::getParticipateStatus, PARTICIPATE_STATUS_ACCEPT)
+                        .eq(VppDrParticipation::getDeleteFlag, VppAuditHelper.NOT_DELETED));
+        Set<Long> eventIds = participations.stream()
+                .map(VppDrParticipation::getEventId)
+                .filter(Objects::nonNull)
+                .collect(Collectors.toSet());
+        if (eventIds.isEmpty()) {
+            return emptyPage(params);
+        }
+
+        List<VppDrEvent> events = eventMapper.selectBatchIds(eventIds).stream()
+                .filter(e -> !VppAuditHelper.isDeleted(e.getDeleteFlag()))
+                .sorted(Comparator.comparing(VppDrEvent::getStartTime, Comparator.nullsLast(Comparator.reverseOrder())))
+                .collect(Collectors.toList());
+
+        Map<Long, VppDrEvaluation> evaluationMap = evaluationMapper.selectList(
+                new LambdaQueryWrapper<VppDrEvaluation>()
+                        .in(VppDrEvaluation::getEventId, eventIds)
+                        .eq(VppDrEvaluation::getDeleteFlag, VppAuditHelper.NOT_DELETED))
+                .stream()
+                .collect(Collectors.toMap(VppDrEvaluation::getEventId, e -> e, (a, b) -> a));
+
+        List<CapabilityHistoryVO> records = events.stream()
+                .map(event -> toHistoryVo(event, evaluationMap.get(event.getId())))
+                .collect(Collectors.toList());
+
+        long current = VppPageHelper.of(params).getCurrent();
+        long size = VppPageHelper.of(params).getSize();
+        int from = (int) Math.min((current - 1) * size, records.size());
+        int to = (int) Math.min(from + size, records.size());
+        List<CapabilityHistoryVO> pageRecords = from >= records.size()
+                ? Collections.emptyList() : records.subList(from, to);
+        return new CommonPage<>(pageRecords, (long) records.size(), current, size);
+    }
+
+    private CapabilityCategoryStatVO buildCategoryStat(String category,
+                                                       List<VppResourcePoint> resources,
+                                                       Map<Long, VppSite> siteMap) {
+        CapabilityCategoryStatVO stat = new CapabilityCategoryStatVO();
+        stat.setCategory(category);
+        stat.setCategoryLabel(VppCapabilityEvalHelper.categoryLabel(category));
+        stat.setCustomerCount(0);
+        stat.setCapacityKw(BigDecimal.ZERO);
+
+        Set<Long> customerIds = new HashSet<>();
+        for (VppResourcePoint resource : resources) {
+            if (!matchesCategory(category, resource.getResourceType())) {
+                continue;
+            }
+            BigDecimal cap = resource.getCapacityKw() != null ? resource.getCapacityKw() : BigDecimal.ZERO;
+            stat.setCapacityKw(stat.getCapacityKw().add(cap));
+            Long customerId = siteResourceHelper.resolveCustomerId(resource, siteMap);
+            if (customerId != null) {
+                customerIds.add(customerId);
+            }
+        }
+        stat.setCustomerCount(customerIds.size());
+        return stat;
+    }
+
+    private boolean matchesCategory(String category, String resourceType) {
+        if (VppCapabilityEvalHelper.CATEGORY_TOTAL.equals(category)) {
+            return true;
+        }
+        return category.equals(VppCapabilityEvalHelper.resolveCategory(resourceType));
+    }
+
+    private CapabilityHistoryVO toHistoryVo(VppDrEvent event, VppDrEvaluation evaluation) {
+        CapabilityHistoryVO vo = new CapabilityHistoryVO();
+        vo.setEventId(event.getId());
+        vo.setEventCode(event.getEventId());
+        vo.setEventName(event.getEventName());
+        vo.setEventType(event.getEventType());
+        vo.setEventTypeLabel(VppCapabilityEvalHelper.eventTypeLabel(event.getEventType()));
+        vo.setResponseType(event.getResponseType());
+        vo.setResponseTypeLabel(VppCapabilityEvalHelper.responseTypeLabel(event.getResponseType()));
+        vo.setStartTime(event.getStartTime());
+        vo.setEndTime(event.getEndTime());
+        vo.setTargetCapacityKw(event.getClearedCapacityKw() != null
+                ? event.getClearedCapacityKw() : event.getTargetCapacityKw());
+        if (evaluation != null) {
+            vo.setActualCapacityKw(evaluation.getActualCapacityKw());
+            vo.setQualifiedRate(evaluation.getQualifiedRate());
+        }
+        return vo;
+    }
+
+    private List<VppResourcePoint> listResources(Long siteId) {
+        LambdaQueryWrapper<VppResourcePoint> wrapper = new LambdaQueryWrapper<VppResourcePoint>()
+                .eq(VppResourcePoint::getDeleteFlag, VppAuditHelper.NOT_DELETED);
+        if (siteId != null) {
+            wrapper.eq(VppResourcePoint::getSiteId, siteId);
+        }
+        return resourcePointMapper.selectList(wrapper);
+    }
+
+    private List<VppDevice> loadDevices(List<VppResourcePoint> resources) {
+        Set<Long> deviceIds = resources.stream()
+                .map(VppResourcePoint::getDeviceId)
+                .filter(Objects::nonNull)
+                .collect(Collectors.toSet());
+        if (deviceIds.isEmpty()) {
+            return Collections.emptyList();
+        }
+        return deviceMapper.selectBatchIds(deviceIds).stream()
+                .filter(d -> !VppAuditHelper.isDeleted(d.getDeleteFlag()))
+                .collect(Collectors.toList());
+    }
+
+    private List<String> resolveDeviceUuids(List<VppResourcePoint> resources, List<VppDevice> devices) {
+        if (resources == null || resources.isEmpty() || devices == null || devices.isEmpty()) {
+            return Collections.emptyList();
+        }
+        Map<Long, VppDevice> deviceMap = devices.stream()
+                .collect(Collectors.toMap(VppDevice::getId, d -> d, (a, b) -> a));
+        Set<String> uuids = new LinkedHashSet<>();
+        for (VppResourcePoint resource : resources) {
+            if (resource.getDeviceId() == null) {
+                continue;
+            }
+            VppDevice device = deviceMap.get(resource.getDeviceId());
+            if (device == null || !StringUtils.hasText(device.getDeviceUuid())) {
+                continue;
+            }
+            if (device.getCommStatus() != null && device.getCommStatus() == 0) {
+                continue;
+            }
+            uuids.add(device.getDeviceUuid().trim());
+        }
+        return new ArrayList<>(uuids);
+    }
+
+    private VppSite resolveSite(Long siteId) {
+        if (siteId == null) {
+            return null;
+        }
+        VppSite site = siteMapper.selectById(siteId);
+        if (site == null || VppAuditHelper.isDeleted(site.getDeleteFlag())) {
+            throw new BusinessException("站点不存在");
+        }
+        return site;
+    }
+
+    private LocalDate parseDate(String date) {
+        if (!StringUtils.hasText(date)) {
+            return LocalDate.now();
+        }
+        try {
+            return LocalDate.parse(date.trim(), DATE_FMT);
+        } catch (DateTimeParseException ex) {
+            throw new BusinessException("日期格式无效,请使用 yyyy-MM-dd");
+        }
+    }
+
+    private CommonPage<CapabilityHistoryVO> emptyPage(Map<String, Object> params) {
+        long current = VppPageHelper.of(params).getCurrent();
+        long size = VppPageHelper.of(params).getSize();
+        return new CommonPage<>(new ArrayList<>(), 0L, current, size);
+    }
+}

+ 170 - 0
service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/service/impl/VppTsdbQueryServiceImpl.java

@@ -0,0 +1,170 @@
+package com.usky.vpp.service.impl;
+
+import com.usky.common.core.bean.ApiResult;
+import com.usky.demo.RemoteTsdbProxyService;
+import com.usky.demo.domain.HistorysInnerRequestVO;
+import com.usky.demo.domain.HistorysInnerResultVO;
+import com.usky.demo.domain.LastInnerQueryVO;
+import com.usky.demo.domain.LastInnerResultVO;
+import com.usky.demo.domain.MetricVO;
+import com.usky.vpp.constant.VppTsdbConstants;
+import com.usky.vpp.service.VppTsdbQueryService;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.stereotype.Service;
+import org.springframework.util.CollectionUtils;
+import org.springframework.util.StringUtils;
+
+import java.math.BigDecimal;
+import java.math.RoundingMode;
+import java.time.LocalDateTime;
+import java.time.format.DateTimeFormatter;
+import java.time.format.DateTimeFormatterBuilder;
+import java.time.temporal.ChronoField;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.TreeMap;
+
+/**
+ * TDengine 时序数据查询(通过 service-tsdb Feign,与 service-ems 一致)
+ */
+@Service
+public class VppTsdbQueryServiceImpl implements VppTsdbQueryService {
+
+    private static final Logger log = LoggerFactory.getLogger(VppTsdbQueryServiceImpl.class);
+
+    private static final DateTimeFormatter TSDB_FLEXIBLE_TIME_FORMAT = new DateTimeFormatterBuilder()
+            .appendPattern("yyyy-MM-dd HH:mm:ss")
+            .optionalStart()
+            .appendFraction(ChronoField.NANO_OF_SECOND, 0, 9, true)
+            .optionalEnd()
+            .toFormatter();
+
+    @Autowired
+    private RemoteTsdbProxyService remoteTsdbProxyService;
+
+    @Override
+    public Map<String, Map<String, TreeMap<LocalDateTime, BigDecimal>>> queryDeviceMetricHistory(
+            List<String> deviceUuids,
+            LocalDateTime startTime,
+            LocalDateTime endTime,
+            List<String> metrics) {
+        if (CollectionUtils.isEmpty(deviceUuids) || startTime == null || endTime == null) {
+            return Collections.emptyMap();
+        }
+
+        HistorysInnerRequestVO request = new HistorysInnerRequestVO();
+        request.setDeviceuuid(new ArrayList<>(new LinkedHashSet<>(deviceUuids)));
+        request.setStartTime(startTime.format(DateTimeFormatter.ofPattern(VppTsdbConstants.TIME_FORMAT)));
+        request.setEndTime(endTime.format(DateTimeFormatter.ofPattern(VppTsdbConstants.TIME_FORMAT)));
+        if (!CollectionUtils.isEmpty(metrics)) {
+            request.setMetrics(new ArrayList<>(metrics));
+        }
+
+        try {
+            ApiResult<List<HistorysInnerResultVO>> result = remoteTsdbProxyService.queryHistoryDeviceData(request);
+            if (result == null || !result.isSuccess() || CollectionUtils.isEmpty(result.getData())) {
+                log.warn("TDengine 历史查询无数据, devices={}, start={}, end={}",
+                        deviceUuids.size(), request.getStartTime(), request.getEndTime());
+                return Collections.emptyMap();
+            }
+            return parseHistoryResult(result.getData());
+        } catch (Exception ex) {
+            log.error("TDengine 历史查询失败: {}", ex.getMessage(), ex);
+            return Collections.emptyMap();
+        }
+    }
+
+    @Override
+    public Map<String, Map<String, BigDecimal>> queryLatestMetrics(List<String> deviceUuids, List<String> metrics) {
+        if (CollectionUtils.isEmpty(deviceUuids)) {
+            return Collections.emptyMap();
+        }
+
+        LastInnerQueryVO request = new LastInnerQueryVO();
+        request.setDeviceuuid(new ArrayList<>(new LinkedHashSet<>(deviceUuids)));
+        if (!CollectionUtils.isEmpty(metrics)) {
+            request.setMetrics(new ArrayList<>(metrics));
+        }
+
+        try {
+            ApiResult<List<LastInnerResultVO>> result = remoteTsdbProxyService.queryLastDeviceData(request);
+            if (result == null || !result.isSuccess() || CollectionUtils.isEmpty(result.getData())) {
+                return Collections.emptyMap();
+            }
+            return parseLatestResult(result.getData());
+        } catch (Exception ex) {
+            log.error("TDengine 最新值查询失败: {}", ex.getMessage(), ex);
+            return Collections.emptyMap();
+        }
+    }
+
+    private Map<String, Map<String, TreeMap<LocalDateTime, BigDecimal>>> parseHistoryResult(
+            List<HistorysInnerResultVO> historyDataList) {
+        Map<String, Map<String, TreeMap<LocalDateTime, BigDecimal>>> deviceMetricMap = new HashMap<>();
+        for (HistorysInnerResultVO result : historyDataList) {
+            if (result == null || !StringUtils.hasText(result.getDeviceuuid()) || CollectionUtils.isEmpty(result.getMetrics())) {
+                continue;
+            }
+            String deviceUuid = result.getDeviceuuid();
+            Map<String, TreeMap<LocalDateTime, BigDecimal>> metricMap =
+                    deviceMetricMap.computeIfAbsent(deviceUuid, key -> new HashMap<>());
+
+            for (MetricVO metric : result.getMetrics()) {
+                if (metric == null || !StringUtils.hasText(metric.getMetric()) || CollectionUtils.isEmpty(metric.getMetricItems())) {
+                    continue;
+                }
+                TreeMap<LocalDateTime, BigDecimal> timeValueMap =
+                        metricMap.computeIfAbsent(metric.getMetric(), key -> new TreeMap<>());
+                for (Map<String, Object> point : metric.getMetricItems()) {
+                    if (point == null || point.get("timestamp") == null || point.get("value") == null) {
+                        continue;
+                    }
+                    try {
+                        LocalDateTime timestamp = parseTimestamp(point.get("timestamp").toString());
+                        BigDecimal value = new BigDecimal(point.get("value").toString());
+                        timeValueMap.put(timestamp, value);
+                    } catch (Exception ignored) {
+                        // skip invalid point
+                    }
+                }
+            }
+        }
+        return deviceMetricMap;
+    }
+
+    private Map<String, Map<String, BigDecimal>> parseLatestResult(List<LastInnerResultVO> latestList) {
+        Map<String, Map<String, BigDecimal>> result = new HashMap<>();
+        for (LastInnerResultVO item : latestList) {
+            if (item == null || !StringUtils.hasText(item.getDeviceuuid()) || item.getMetrics() == null) {
+                continue;
+            }
+            Map<String, BigDecimal> metricValues = new HashMap<>();
+            item.getMetrics().forEach((metric, value) -> {
+                if (value == null) {
+                    return;
+                }
+                try {
+                    metricValues.put(metric, new BigDecimal(value.toString()).setScale(4, RoundingMode.HALF_UP));
+                } catch (Exception ignored) {
+                    // skip invalid value
+                }
+            });
+            result.put(item.getDeviceuuid(), metricValues);
+        }
+        return result;
+    }
+
+    private LocalDateTime parseTimestamp(String timestampStr) {
+        String normalized = timestampStr.trim();
+        if (normalized.endsWith(".0")) {
+            normalized = normalized.substring(0, normalized.length() - 2);
+        }
+        return LocalDateTime.parse(normalized, TSDB_FLEXIBLE_TIME_FORMAT);
+    }
+}

+ 21 - 0
service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/service/vo/CapabilityCategoryStatVO.java

@@ -0,0 +1,21 @@
+package com.usky.vpp.service.vo;
+
+import lombok.Data;
+
+import java.math.BigDecimal;
+
+/**
+ * 可调能力评估 - 分类统计(户数/容量)
+ */
+@Data
+public class CapabilityCategoryStatVO {
+
+    /** TOTAL / LOAD / STORAGE / GENERATION */
+    private String category;
+    /** 分类中文名 */
+    private String categoryLabel;
+    /** 户数(去重客户数) */
+    private Integer customerCount;
+    /** 装机容量 kW */
+    private BigDecimal capacityKw;
+}

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

@@ -0,0 +1,27 @@
+package com.usky.vpp.service.vo;
+
+import lombok.Data;
+
+import java.math.BigDecimal;
+
+/**
+ * 可调能力评估 - 顶部汇总卡片
+ */
+@Data
+public class CapabilityEvalSummaryVO {
+
+    private Long siteId;
+    private String siteName;
+    /** 总资源 */
+    private CapabilityCategoryStatVO total;
+    /** 负荷类 */
+    private CapabilityCategoryStatVO load;
+    /** 储能类 */
+    private CapabilityCategoryStatVO storage;
+    /** 发电类 */
+    private CapabilityCategoryStatVO generation;
+    /** 最大上调能力 kW */
+    private BigDecimal maxUpKw;
+    /** 最大下调能力 kW */
+    private BigDecimal maxDownKw;
+}

+ 31 - 0
service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/service/vo/CapabilityHistoryVO.java

@@ -0,0 +1,31 @@
+package com.usky.vpp.service.vo;
+
+import lombok.Data;
+
+import java.math.BigDecimal;
+import java.time.LocalDateTime;
+
+/**
+ * 可调能力评估 - 历史响应记录
+ */
+@Data
+public class CapabilityHistoryVO {
+
+    private Long eventId;
+    private String eventCode;
+    private String eventName;
+    /** 1削峰 2填谷 */
+    private Integer eventType;
+    private String eventTypeLabel;
+    /** 1日前 2日内 3秒级 */
+    private Integer responseType;
+    private String responseTypeLabel;
+    private LocalDateTime startTime;
+    private LocalDateTime endTime;
+    /** 目标/申报容量 kW */
+    private BigDecimal targetCapacityKw;
+    /** 实际响应容量 kW */
+    private BigDecimal actualCapacityKw;
+    /** 达标率 % */
+    private BigDecimal qualifiedRate;
+}

+ 20 - 0
service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/service/vo/CapabilityLoadCurveVO.java

@@ -0,0 +1,20 @@
+package com.usky.vpp.service.vo;
+
+import lombok.Data;
+
+import java.util.List;
+
+/**
+ * 可调能力曲线
+ */
+@Data
+public class CapabilityLoadCurveVO {
+
+    private Long siteId;
+    private String siteName;
+    /** 统计日期 yyyy-MM-dd */
+    private String date;
+    /** 数据来源:tsdb / mock */
+    private String dataSource;
+    private List<CapabilityLoadPointVO> points;
+}

+ 19 - 0
service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/service/vo/CapabilityLoadPointVO.java

@@ -0,0 +1,19 @@
+package com.usky.vpp.service.vo;
+
+import lombok.Data;
+
+import java.math.BigDecimal;
+
+/**
+ * 可调能力曲线 - 单时间点负荷
+ */
+@Data
+public class CapabilityLoadPointVO {
+
+    /** 时刻 HH:mm */
+    private String time;
+    /** 基线负荷 kW */
+    private BigDecimal baselineLoadKw;
+    /** 实际负荷 kW */
+    private BigDecimal actualLoadKw;
+}

+ 364 - 0
service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/util/VppCapabilityEvalHelper.java

@@ -0,0 +1,364 @@
+package com.usky.vpp.util;
+
+import com.usky.vpp.domain.VppDevice;
+import com.usky.vpp.domain.VppResourcePoint;
+import com.usky.vpp.service.vo.CapabilityLoadPointVO;
+
+import java.math.BigDecimal;
+import java.math.RoundingMode;
+import java.time.LocalDate;
+import java.time.LocalDateTime;
+import java.time.LocalTime;
+import java.time.format.DateTimeFormatter;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.TreeMap;
+
+/**
+ * 可调能力评估辅助(TSDB 聚合 + 模拟数据兜底)
+ */
+public final class VppCapabilityEvalHelper {
+
+    public static final String CATEGORY_TOTAL = "TOTAL";
+    public static final String CATEGORY_LOAD = "LOAD";
+    public static final String CATEGORY_STORAGE = "STORAGE";
+    public static final String CATEGORY_GENERATION = "GENERATION";
+
+    private static final Set<String> LOAD_TYPES = new HashSet<>(Arrays.asList("IND_LOAD", "COM_BLDG", "EVCS"));
+    private static final Set<String> STORAGE_TYPES = Collections.singleton("ESS");
+    private static final Set<String> GENERATION_TYPES = Collections.singleton("PV");
+
+    private static final DateTimeFormatter TIME_FMT = DateTimeFormatter.ofPattern("HH:mm");
+
+    private VppCapabilityEvalHelper() {
+    }
+
+    public static String categoryLabel(String category) {
+        if (category == null) {
+            return "未知";
+        }
+        switch (category) {
+            case CATEGORY_TOTAL:
+                return "总资源";
+            case CATEGORY_LOAD:
+                return "负荷类";
+            case CATEGORY_STORAGE:
+                return "储能类";
+            case CATEGORY_GENERATION:
+                return "发电类";
+            default:
+                return category;
+        }
+    }
+
+    public static String resolveCategory(String resourceType) {
+        if (resourceType == null) {
+            return CATEGORY_LOAD;
+        }
+        String type = resourceType.toUpperCase();
+        if (GENERATION_TYPES.contains(type)) {
+            return CATEGORY_GENERATION;
+        }
+        if (STORAGE_TYPES.contains(type)) {
+            return CATEGORY_STORAGE;
+        }
+        if (LOAD_TYPES.contains(type)) {
+            return CATEGORY_LOAD;
+        }
+        return CATEGORY_LOAD;
+    }
+
+    public static String eventTypeLabel(Integer eventType) {
+        if (eventType == null) {
+            return "未知";
+        }
+        return eventType == 1 ? "削峰" : eventType == 2 ? "填谷" : "未知";
+    }
+
+    public static String responseTypeLabel(Integer responseType) {
+        if (responseType == null) {
+            return "未知";
+        }
+        switch (responseType) {
+            case 1:
+                return "日前";
+            case 2:
+                return "日内";
+            case 3:
+                return "秒级";
+            default:
+                return "未知";
+        }
+    }
+
+    /**
+     * 基于 TDengine 历史数据构建负荷曲线(每 10 分钟一个点)
+     * <p>实际负荷取统计日实测;基线负荷取 7 日前同时段作为参考基线。</p>
+     */
+    public static List<CapabilityLoadPointVO> buildLoadCurveFromTsdb(
+            Map<String, Map<String, TreeMap<LocalDateTime, BigDecimal>>> actualData,
+            Map<String, Map<String, TreeMap<LocalDateTime, BigDecimal>>> baselineData,
+            LocalDate date,
+            List<String> powerMetrics) {
+        List<CapabilityLoadPointVO> points = new ArrayList<>();
+        for (int minute = 0; minute < 1440; minute += 10) {
+            LocalTime time = LocalTime.of(minute / 60, minute % 60);
+            LocalDateTime actualStart = date.atTime(time);
+            LocalDateTime actualEnd = actualStart.plusMinutes(10).minusSeconds(1);
+            LocalDateTime baselineStart = date.minusDays(7).atTime(time);
+            LocalDateTime baselineEnd = baselineStart.plusMinutes(10).minusSeconds(1);
+
+            BigDecimal actual = sumBucketPower(actualData, powerMetrics, actualStart, actualEnd);
+            BigDecimal baseline = sumBucketPower(baselineData, powerMetrics, baselineStart, baselineEnd);
+            if (baseline == null || baseline.compareTo(BigDecimal.ZERO) <= 0) {
+                baseline = actual;
+            }
+
+            CapabilityLoadPointVO point = new CapabilityLoadPointVO();
+            point.setTime(time.format(TIME_FMT));
+            point.setBaselineLoadKw(scale(baseline));
+            point.setActualLoadKw(scale(actual));
+            points.add(point);
+        }
+        return points;
+    }
+
+    /**
+     * 汇总设备最新上下调能力(TDengine 实时指标)
+     */
+    public static BigDecimal sumLatestMetric(Map<String, Map<String, BigDecimal>> latestData,
+                                             List<String> metricCandidates) {
+        if (latestData == null || latestData.isEmpty()) {
+            return BigDecimal.ZERO;
+        }
+        BigDecimal total = BigDecimal.ZERO;
+        for (Map<String, BigDecimal> metrics : latestData.values()) {
+            BigDecimal value = pickMetric(metrics, metricCandidates);
+            if (value != null) {
+                total = total.add(value);
+            }
+        }
+        return total.setScale(2, RoundingMode.HALF_UP);
+    }
+
+    public static boolean hasTsdbPowerData(Map<String, Map<String, TreeMap<LocalDateTime, BigDecimal>>> deviceMetricData,
+                                           List<String> powerMetrics) {
+        if (deviceMetricData == null || deviceMetricData.isEmpty()) {
+            return false;
+        }
+        for (Map<String, TreeMap<LocalDateTime, BigDecimal>> metricMap : deviceMetricData.values()) {
+            for (String metric : powerMetrics) {
+                TreeMap<LocalDateTime, BigDecimal> series = metricMap.get(metric);
+                if (series != null && !series.isEmpty()) {
+                    return true;
+                }
+            }
+        }
+        return false;
+    }
+
+    /**
+     * 生成当日负荷曲线(每 10 分钟一个点,00:00 ~ 23:50)- 模拟兜底
+     */
+    public static List<CapabilityLoadPointVO> buildLoadCurve(List<VppResourcePoint> resources,
+                                                           List<VppDevice> devices,
+                                                           Long siteId,
+                                                           LocalDate date) {
+        List<CapabilityLoadPointVO> points = new ArrayList<>();
+        long seed = (siteId != null ? siteId : 0L) + date.toEpochDay();
+        for (int minute = 0; minute < 1440; minute += 10) {
+            LocalTime time = LocalTime.of(minute / 60, minute % 60);
+            double hourFactor = timeFactor(time);
+            BigDecimal baseline = aggregateLoad(resources, devices, seed, minute, hourFactor, 0);
+            BigDecimal actual = aggregateLoad(resources, devices, seed, minute, hourFactor, 1);
+            CapabilityLoadPointVO point = new CapabilityLoadPointVO();
+            point.setTime(time.format(TIME_FMT));
+            point.setBaselineLoadKw(baseline);
+            point.setActualLoadKw(actual);
+            points.add(point);
+        }
+        return points;
+    }
+
+    private static BigDecimal aggregateLoad(List<VppResourcePoint> resources,
+                                            List<VppDevice> devices,
+                                            long seed,
+                                            int minuteOfDay,
+                                            double hourFactor,
+                                            int seriesSalt) {
+        if (resources == null || resources.isEmpty()) {
+            double base = 800 * hourFactor + pseudoRandom(seed, minuteOfDay, seriesSalt) * 120;
+            return BigDecimal.valueOf(base).setScale(2, RoundingMode.HALF_UP);
+        }
+        BigDecimal total = BigDecimal.ZERO;
+        for (VppResourcePoint resource : resources) {
+            VppDevice device = findDevice(devices, resource.getDeviceId());
+            if (device != null && device.getCommStatus() != null && device.getCommStatus() == 0) {
+                continue;
+            }
+            BigDecimal cap = resource.getCapacityKw() != null ? resource.getCapacityKw() : BigDecimal.valueOf(100);
+            double typeFactor = typeLoadFactor(resource.getResourceType());
+            double noise = 0.85 + pseudoRandom(seed, resource.getId() != null ? resource.getId() : 0L, minuteOfDay, seriesSalt) * 0.3;
+            double value = cap.doubleValue() * hourFactor * typeFactor * noise;
+            if (CATEGORY_GENERATION.equals(resolveCategory(resource.getResourceType()))) {
+                value *= 0.15;
+            }
+            total = total.add(BigDecimal.valueOf(value));
+        }
+        if (seriesSalt == 1) {
+            total = total.multiply(BigDecimal.valueOf(1.02 + pseudoRandom(seed, 999, minuteOfDay, seriesSalt) * 0.08));
+        }
+        return total.setScale(2, RoundingMode.HALF_UP);
+    }
+
+    private static BigDecimal sumBucketPower(Map<String, Map<String, TreeMap<LocalDateTime, BigDecimal>>> deviceData,
+                                             List<String> metricCandidates,
+                                             LocalDateTime bucketStart,
+                                             LocalDateTime bucketEnd) {
+        if (deviceData == null || deviceData.isEmpty()) {
+            return BigDecimal.ZERO;
+        }
+        BigDecimal total = BigDecimal.ZERO;
+        for (Map<String, TreeMap<LocalDateTime, BigDecimal>> metricMap : deviceData.values()) {
+            TreeMap<LocalDateTime, BigDecimal> series = pickSeries(metricMap, metricCandidates);
+            if (series == null || series.isEmpty()) {
+                continue;
+            }
+            BigDecimal avg = averageInRange(series, bucketStart, bucketEnd);
+            if (avg != null) {
+                total = total.add(avg);
+            }
+        }
+        return total;
+    }
+
+    private static TreeMap<LocalDateTime, BigDecimal> pickSeries(Map<String, TreeMap<LocalDateTime, BigDecimal>> metricMap,
+                                                                 List<String> metricCandidates) {
+        if (metricMap == null || metricMap.isEmpty()) {
+            return null;
+        }
+        for (String metric : metricCandidates) {
+            TreeMap<LocalDateTime, BigDecimal> series = metricMap.get(metric);
+            if (series != null && !series.isEmpty()) {
+                return series;
+            }
+        }
+        for (Map.Entry<String, TreeMap<LocalDateTime, BigDecimal>> entry : metricMap.entrySet()) {
+            if (entry.getValue() != null && !entry.getValue().isEmpty()) {
+                return entry.getValue();
+            }
+        }
+        return null;
+    }
+
+    private static BigDecimal pickMetric(Map<String, BigDecimal> metrics, List<String> metricCandidates) {
+        if (metrics == null || metrics.isEmpty()) {
+            return null;
+        }
+        for (String metric : metricCandidates) {
+            BigDecimal value = metrics.get(metric);
+            if (value != null) {
+                return value;
+            }
+        }
+        return null;
+    }
+
+    private static BigDecimal averageInRange(TreeMap<LocalDateTime, BigDecimal> series,
+                                             LocalDateTime startInclusive,
+                                             LocalDateTime endInclusive) {
+        BigDecimal sum = BigDecimal.ZERO;
+        int count = 0;
+        for (Map.Entry<LocalDateTime, BigDecimal> entry : series.subMap(startInclusive, true, endInclusive, true).entrySet()) {
+            if (entry.getValue() != null) {
+                sum = sum.add(entry.getValue());
+                count++;
+            }
+        }
+        if (count == 0) {
+            Map.Entry<LocalDateTime, BigDecimal> floor = series.floorEntry(startInclusive);
+            if (floor != null && floor.getValue() != null) {
+                return floor.getValue();
+            }
+            return null;
+        }
+        return sum.divide(BigDecimal.valueOf(count), 4, RoundingMode.HALF_UP);
+    }
+
+    private static BigDecimal scale(BigDecimal value) {
+        if (value == null) {
+            return BigDecimal.ZERO.setScale(2, RoundingMode.HALF_UP);
+        }
+        return value.setScale(2, RoundingMode.HALF_UP);
+    }
+
+    private static VppDevice findDevice(List<VppDevice> devices, Long deviceId) {
+        if (devices == null || deviceId == null) {
+            return null;
+        }
+        return devices.stream().filter(d -> deviceId.equals(d.getId())).findFirst().orElse(null);
+    }
+
+    /**
+     * 日内负荷形态:夜间低、早晚峰、午间次峰
+     */
+    private static double timeFactor(LocalTime time) {
+        int hour = time.getHour();
+        int minute = time.getMinute();
+        double h = hour + minute / 60.0;
+        if (h < 6) {
+            return 0.45 + h / 6 * 0.15;
+        }
+        if (h < 9) {
+            return 0.6 + (h - 6) / 3 * 0.35;
+        }
+        if (h < 12) {
+            return 0.95 + (h - 9) / 3 * 0.05;
+        }
+        if (h < 14) {
+            return 0.85;
+        }
+        if (h < 17) {
+            return 0.9 + (h - 14) / 3 * 0.1;
+        }
+        if (h < 21) {
+            return 1.0 - (h - 17) / 4 * 0.35;
+        }
+        return 0.5 - (h - 21) / 3 * 0.15;
+    }
+
+    private static double typeLoadFactor(String resourceType) {
+        if (resourceType == null) {
+            return 0.7;
+        }
+        switch (resourceType.toUpperCase()) {
+            case "IND_LOAD":
+                return 0.75;
+            case "COM_BLDG":
+                return 0.65;
+            case "EVCS":
+                return 0.55;
+            case "ESS":
+                return 0.4;
+            case "PV":
+                return 1.0;
+            default:
+                return 0.7;
+        }
+    }
+
+    private static double pseudoRandom(long seed, long a, int b, int c) {
+        long mixed = seed * 31L + a * 17L + b * 13L + c * 7L;
+        return (mixed % 1000) / 1000.0;
+    }
+
+    private static double pseudoRandom(long seed, int minuteOfDay, int seriesSalt) {
+        long mixed = seed * 31L + minuteOfDay * 17L + seriesSalt * 13L;
+        return (mixed % 1000) / 1000.0;
+    }
+}

+ 16 - 15
service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/util/VppSmsTemplateHelper.java

@@ -10,11 +10,11 @@ import java.time.format.DateTimeFormatter;
 /**
  * 邀约短信模板参数构建
  * <p>
- * 阿里云模板变量需与控制台一致,默认使用:name、event、capacity、deadline
+ * 阿里云模板变量需与控制台一致,默认使用:meet、time、room(SMS_465362899)
  */
 public final class VppSmsTemplateHelper {
 
-    private static final DateTimeFormatter DEADLINE_FORMAT = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm");
+    private static final DateTimeFormatter TIME_FORMAT = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
 
     private VppSmsTemplateHelper() {
     }
@@ -23,20 +23,21 @@ public final class VppSmsTemplateHelper {
                                             VppDrEvent event,
                                             VppCustomer customer,
                                             VppCustomerContact contact) {
-        String name = contact != null && contact.getContactName() != null
-                ? contact.getContactName()
-                : (customer != null ? customer.getCustomerName() : "");
-        String eventCode = event != null ? event.getEventId() : "";
-        String capacity = invitation.getDemandCapacityKw() != null
-                ? invitation.getDemandCapacityKw().stripTrailingZeros().toPlainString()
-                : "";
-        String deadline = invitation.getReplyDeadline() != null
-                ? DEADLINE_FORMAT.format(invitation.getReplyDeadline())
+        String meet = event != null && event.getEventName() != null
+                ? event.getEventName()
+                : (event != null ? event.getEventId() : "");
+        String time = "";
+        if (event != null && event.getStartTime() != null) {
+            time = TIME_FORMAT.format(event.getStartTime());
+        } else if (invitation.getReplyDeadline() != null) {
+            time = TIME_FORMAT.format(invitation.getReplyDeadline());
+        }
+        String room = customer != null && customer.getCustomerName() != null
+                ? customer.getCustomerName()
                 : "";
-        return "{\"name\":\"" + escapeJson(name)
-                + "\",\"event\":\"" + escapeJson(eventCode)
-                + "\",\"capacity\":\"" + escapeJson(capacity)
-                + "\",\"deadline\":\"" + escapeJson(deadline) + "\"}";
+        return "{\"meet\":\"" + escapeJson(meet)
+                + "\",\"time\":\"" + escapeJson(time)
+                + "\",\"room\":\"" + escapeJson(room) + "\"}";
     }
 
     private static String escapeJson(String value) {

+ 1 - 1
service-vpp/service-vpp-biz/src/main/resources/bootstrap.yml

@@ -35,4 +35,4 @@ vpp:
   sms:
     enabled: true
     sign-name: 上海永天科技股份有限公司
-    invitation-template-code: SMS_xxxxxx
+    invitation-template-code: SMS_465362899

+ 94 - 0
service-vpp/service-vpp-biz/src/main/resources/logback.xml

@@ -0,0 +1,94 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<configuration scan="true" scanPeriod="60 seconds" debug="false">
+    <!-- 日志存放路径 -->
+    <property name="log.path" value="/var/log/uskycloud/service-vpp" />
+    <!-- 日志输出格式 -->
+    <property name="log.pattern" value="%d{MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{26}:%line: %msg%n" />
+    <!--    	<property name="log.pattern" value="%gray(%d{MM-dd HH:mm:ss.SSS}) %highlight(%-5level) &#45;&#45; [%gray(%thread)] %cyan(%logger{26}:%line): %msg%n" />-->
+
+
+    <property name="SQL_PACKAGE" value="com.usky.vpp.mapper"/>
+
+    <!-- 控制台输出 -->
+    <appender name="console" class="ch.qos.logback.core.ConsoleAppender">
+        <encoder>
+            <pattern>${log.pattern}</pattern>
+        </encoder>
+    </appender>
+
+    <appender name="file_sql" class="ch.qos.logback.core.rolling.RollingFileAppender">
+        <file>${log.path}/sql.log</file>
+        <!-- 循环政策:基于时间创建日志文件 -->
+        <rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy">
+            <!-- 日志文件名格式 -->
+            <fileNamePattern>${log.path}/sql.%d{yyyy-MM-dd}.log</fileNamePattern>
+            <!-- 日志最大的历史 60天 -->
+            <maxHistory>3</maxHistory>
+        </rollingPolicy>
+        <encoder>
+            <pattern>${log.pattern}</pattern>
+        </encoder>
+    </appender>
+
+    <!-- 系统日志输出 -->
+    <appender name="file_info" class="ch.qos.logback.core.rolling.RollingFileAppender">
+        <file>${log.path}/info.log</file>
+        <!-- 循环政策:基于时间创建日志文件 -->
+        <rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy">
+            <!-- 日志文件名格式 -->
+            <fileNamePattern>${log.path}/info.%d{yyyy-MM-dd}.log</fileNamePattern>
+            <!-- 日志最大的历史 60天 -->
+            <maxHistory>3</maxHistory>
+        </rollingPolicy>
+        <encoder>
+            <pattern>${log.pattern}</pattern>
+        </encoder>
+        <filter class="ch.qos.logback.classic.filter.LevelFilter">
+            <!-- 过滤的级别 -->
+            <level>INFO</level>
+            <!-- 匹配时的操作:接收(记录) -->
+            <onMatch>ACCEPT</onMatch>
+            <!-- 不匹配时的操作:拒绝(不记录) -->
+            <onMismatch>DENY</onMismatch>
+        </filter>
+    </appender>
+
+    <appender name="file_error" class="ch.qos.logback.core.rolling.RollingFileAppender">
+        <file>${log.path}/error.log</file>
+        <!-- 循环政策:基于时间创建日志文件 -->
+        <rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy">
+            <!-- 日志文件名格式 -->
+            <fileNamePattern>${log.path}/error.%d{yyyy-MM-dd}.log</fileNamePattern>
+            <!-- 日志最大的历史 60天 -->
+            <maxHistory>60</maxHistory>
+        </rollingPolicy>
+        <encoder>
+            <pattern>${log.pattern}</pattern>
+        </encoder>
+        <filter class="ch.qos.logback.classic.filter.LevelFilter">
+            <!-- 过滤的级别 -->
+            <level>ERROR</level>
+            <!-- 匹配时的操作:接收(记录) -->
+            <onMatch>ACCEPT</onMatch>
+            <!-- 不匹配时的操作:拒绝(不记录) -->
+            <onMismatch>DENY</onMismatch>
+        </filter>
+    </appender>
+
+    <!-- 系统模块日志级别控制  -->
+    <!--	<logger name="com.usky" level="info" />-->
+    <!-- Spring日志级别控制  -->
+    <!--	<logger name="org.springframework" level="warn" />-->
+
+    <logger name="${SQL_PACKAGE}" additivity="false" level="debug">
+        <appender-ref ref="console"/>
+        <appender-ref ref="file_sql"/>
+    </logger>
+
+    <!--系统操作日志-->
+    <root level="info">
+        <appender-ref ref="file_info" />
+        <appender-ref ref="file_error" />
+        <appender-ref ref="console" />
+    </root>
+</configuration>