Ver código fonte

Merge branch 'vpp-zyj' into feature/service-vpp-20260701

james 6 dias atrás
pai
commit
c28f72a29f
16 arquivos alterados com 1063 adições e 181 exclusões
  1. 5 0
      service-vpp/service-vpp-biz/pom.xml
  2. 57 0
      service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/controller/web/SiteController.java
  3. 0 2
      service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/domain/VppDevice.java
  4. 2 0
      service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/domain/VppSite.java
  5. 20 0
      service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/service/VppBaselineService.java
  6. 16 0
      service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/service/VppTsdbQueryService.java
  7. 266 0
      service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/service/impl/VppBaselineServiceImpl.java
  8. 0 7
      service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/service/impl/VppDeviceServiceImpl.java
  9. 7 0
      service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/service/impl/VppSiteServiceImpl.java
  10. 196 170
      service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/service/impl/VppTsdbQueryServiceImpl.java
  11. 21 0
      service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/service/vo/BaselinePointVO.java
  12. 0 2
      service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/service/vo/DeviceRequest.java
  13. 31 0
      service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/service/vo/SiteBaselineVO.java
  14. 2 0
      service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/service/vo/SiteRequest.java
  15. 393 0
      service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/util/VppBaselineHelper.java
  16. 47 0
      service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/util/VppWorkdayHelper.java

+ 5 - 0
service-vpp/service-vpp-biz/pom.xml

@@ -78,6 +78,11 @@
             <artifactId>hutool-crypto</artifactId>
             <version>5.8.41</version>
         </dependency>
+        <dependency>
+            <groupId>cn.hutool</groupId>
+            <artifactId>hutool-core</artifactId>
+            <version>5.8.41</version>
+        </dependency>
 
         <!-- TDengine JDBC(设备运行曲线) -->
         <dependency>

+ 57 - 0
service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/controller/web/SiteController.java

@@ -5,11 +5,13 @@ import com.usky.common.core.bean.CommonPage;
 import com.usky.vpp.domain.VppDevice;
 import com.usky.vpp.domain.VppResourcePoint;
 import com.usky.vpp.domain.VppSite;
+import com.usky.vpp.service.VppBaselineService;
 import com.usky.vpp.service.VppDeviceService;
 import com.usky.vpp.service.VppResourcePointService;
 import com.usky.vpp.service.VppSiteService;
 import com.usky.vpp.service.vo.DeviceRequest;
 import com.usky.vpp.service.vo.ResourcePointRequest;
+import com.usky.vpp.service.vo.SiteBaselineVO;
 import com.usky.vpp.service.vo.SiteConfigDetailVO;
 import com.usky.vpp.service.vo.SiteConfigRequest;
 import com.usky.vpp.service.vo.SiteListVO;
@@ -30,6 +32,8 @@ public class SiteController {
     @Autowired
     private VppSiteService vppSiteService;
     @Autowired
+    private VppBaselineService vppBaselineService;
+    @Autowired
     private VppResourcePointService vppResourcePointService;
     @Autowired
     private VppDeviceService vppDeviceService;
@@ -114,6 +118,59 @@ public class SiteController {
         return ApiResult.success(vppDeviceService.createDevice(siteId, body));
     }
 
+    /**
+     * 基线管理 - 负荷曲线
+     * <p><b>GET</b> /site/{siteId}/baseline</p>
+     * <p>完整路径:GET /prod-api/service-vpp/site/{siteId}/baseline</p>
+     *
+     * <p>根据站点 ID 与响应开始时间,返回需求响应基线管理页面所需的三条负荷曲线:</p>
+     * <ul>
+     *   <li>预测基线(黄色):典型历史日同时段各时间点负荷均值 × 修正系数 K</li>
+     *   <li>今日实际(绿色):响应日当天 00:00 至响应开始时间的实测负荷</li>
+     *   <li>昨日实际(蓝色):响应日前一日全天实测负荷</li>
+     * </ul>
+     *
+     * <p><b>路径参数</b></p>
+     * <ul>
+     *   <li>siteId — 站点 ID</li>
+     * </ul>
+     *
+     * <p><b>Query 参数</b></p>
+     * <ul>
+     *   <li>responseStartTime — 响应开始时间,必填,格式 yyyy-MM-dd HH:mm:ss</li>
+     * </ul>
+     *
+     * <p><b>典型历史日选取规则</b>(基于 Hutool 法定节假日 + 调休判定)</p>
+     * <ul>
+     *   <li>响应日为工作日:取邀约日前最近 5 个工作日;剔除响应当日、历史响应日(vpp_dr_event 已结束)</li>
+     *   <li>响应日为非工作日:取邀约日前最近 3 个非工作日(含法定节假日);剔除响应当日、历史响应日</li>
+     * </ul>
+     *
+     * <p><b>修正系数 K</b>:取响应开始时间前连续 2 小时今日实测与同时段原始基线之比,限制在 0.7 ~ 1.2。</p>
+     * <p>原始基线在本模块内对多历史日同时段各时间点(默认 10 分钟粒度)求平均,
+     * 逐日调用 service-tsdb {@code queryHistoryDeviceData} 获取设备时序后聚合;
+     * 设备范围为站点下 is_control=1 的资源点关联设备。</p>
+     *
+     * <p><b>响应 data:SiteBaselineVO</b></p>
+     * <ul>
+     *   <li>siteId / siteName — 站点信息</li>
+     *   <li>responseStartTime — 响应开始时间</li>
+     *   <li>responseDayType — WORKDAY(工作日)/ NON_WORKDAY(非工作日)</li>
+     *   <li>responseDayTypeLabel — 工作日 / 非工作日</li>
+     *   <li>referenceDates — 参与计算的典型历史日(yyyy-MM-dd)</li>
+     *   <li>correctionFactorK — 日内修正系数 K</li>
+     *   <li>dataSource — tsdb(时序库)/ mock(联调模拟)</li>
+     *   <li>points — 曲线点列表(BaselinePointVO:time、predictedBaselineKw、todayActualKw、yesterdayActualKw)</li>
+     * </ul>
+     *
+     * <p><b>示例</b>:GET /site/1/baseline?responseStartTime=2026-07-08 19:00:00</p>
+     */
+    @GetMapping("/{siteId}/baseline")
+    public ApiResult<SiteBaselineVO> getSiteBaseline(@PathVariable("siteId") Long siteId,
+                                                     @RequestParam("responseStartTime") String responseStartTime) {
+        return ApiResult.success(vppBaselineService.getSiteBaseline(siteId, responseStartTime));
+    }
+
     /**
      * 站点下资源点列表
      */

+ 0 - 2
service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/domain/VppDevice.java

@@ -61,6 +61,4 @@ public class VppDevice implements Serializable {
     private Integer deleteFlag;
     @TableField("deleted_at")
     private LocalDateTime deletedAt;
-    private String accountNo;
-    private String streetTown;
 }

+ 2 - 0
service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/domain/VppSite.java

@@ -60,4 +60,6 @@ public class VppSite implements Serializable {
     private Integer deleteFlag;
     @TableField("deleted_at")
     private LocalDateTime deletedAt;
+    private String accountNo;
+    private String streetTown;
 }

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

@@ -0,0 +1,20 @@
+package com.usky.vpp.service;
+
+import com.usky.vpp.service.vo.SiteBaselineVO;
+
+/**
+ * 站点基线管理
+ */
+public interface VppBaselineService {
+
+    /**
+     * 查询站点基线负荷曲线(预测基线 / 今日实际 / 昨日实际)。
+     *
+     * <p>详见 {@link com.usky.vpp.service.impl.VppBaselineServiceImpl#getSiteBaseline(Long, String)} 实现说明。</p>
+     *
+     * @param siteId            站点 ID
+     * @param responseStartTime 响应开始时间,格式 yyyy-MM-dd HH:mm:ss
+     * @return 基线曲线及修正系数等信息
+     */
+    SiteBaselineVO getSiteBaseline(Long siteId, String responseStartTime);
+}

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

@@ -1,7 +1,9 @@
 package com.usky.vpp.service;
 
 import java.math.BigDecimal;
+import java.time.LocalDate;
 import java.time.LocalDateTime;
+import java.time.LocalTime;
 import java.util.List;
 import java.util.Map;
 import java.util.TreeMap;
@@ -28,4 +30,18 @@ public interface VppTsdbQueryService {
      * @return deviceUuid -> metric -> value
      */
     Map<String, Map<String, BigDecimal>> queryLatestMetrics(List<String> deviceUuids, List<String> metrics);
+
+    /**
+     * 多历史日同时段各时间点负荷求平均(原始/预测基线)。
+     * <p>逐日调用 {@link #queryDeviceMetricHistory}(service-tsdb queryHistoryDeviceData)后在本模块聚合。</p>
+     *
+     * @return time(HH:mm) -> 平均负荷 kW
+     */
+    Map<String, BigDecimal> queryBaselineAvgByTimePoint(List<String> deviceUuids,
+                                                        List<LocalDate> referenceDates,
+                                                        LocalTime periodStart,
+                                                        LocalTime periodEnd,
+                                                        List<String> metrics,
+                                                        Integer intervalMinutes,
+                                                        BigDecimal correctionFactor);
 }

+ 266 - 0
service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/service/impl/VppBaselineServiceImpl.java

@@ -0,0 +1,266 @@
+package com.usky.vpp.service.impl;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.usky.common.core.exception.BusinessException;
+import com.usky.vpp.domain.VppDevice;
+import com.usky.vpp.domain.VppDrEvent;
+import com.usky.vpp.domain.VppResourcePoint;
+import com.usky.vpp.domain.VppSite;
+import com.usky.vpp.mapper.VppDeviceMapper;
+import com.usky.vpp.mapper.VppDrEventMapper;
+import com.usky.vpp.mapper.VppResourcePointMapper;
+import com.usky.vpp.mapper.VppSiteMapper;
+import com.usky.vpp.service.VppBaselineService;
+import com.usky.vpp.service.VppTsdbQueryService;
+import com.usky.vpp.service.vo.BaselinePointVO;
+import com.usky.vpp.service.vo.SiteBaselineVO;
+import com.usky.vpp.util.VppAuditHelper;
+import com.usky.vpp.util.VppBaselineHelper;
+import com.usky.vpp.util.VppWorkdayHelper;
+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.time.LocalDate;
+import java.time.LocalDateTime;
+import java.time.LocalTime;
+import java.time.format.DateTimeFormatter;
+import java.time.format.DateTimeParseException;
+import java.util.ArrayList;
+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 VppBaselineServiceImpl implements VppBaselineService {
+
+    private static final int EVENT_STATUS_ENDED = 3;
+    private static final DateTimeFormatter DATE_TIME_FMT = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
+
+    @Autowired
+    private VppSiteMapper siteMapper;
+    @Autowired
+    private VppResourcePointMapper resourcePointMapper;
+    @Autowired
+    private VppDeviceMapper deviceMapper;
+    @Autowired
+    private VppDrEventMapper drEventMapper;
+    @Autowired
+    private VppTsdbQueryService vppTsdbQueryService;
+
+    /**
+     * 查询站点基线负荷曲线(预测基线 / 今日实际 / 昨日实际)。
+     *
+     * <p><b>入参</b></p>
+     * <ul>
+     *   <li>siteId — 站点 ID,校验站点存在且未软删除</li>
+     *   <li>responseStartTime — 响应开始时间,格式 yyyy-MM-dd HH:mm:ss</li>
+     * </ul>
+     *
+     * <p><b>处理流程</b></p>
+     * <ol>
+     *   <li>查询站点下 is_control=1 的资源点关联设备 UUID;无设备则返回 mock 数据</li>
+     *   <li>按 Hutool 法定节假日规则判定响应日类型(工作日 / 非工作日),选取典型历史日:
+     *       工作日 5 天 / 非工作日 3 天,剔除历史响应日</li>
+     *   <li>本模块按典型历史日逐日调用 TSDB {@code queryHistoryDeviceData},聚合计算全天原始基线(10 分钟粒度)</li>
+     *   <li>取响应开始时间前连续 2 小时的今日实时负荷(绿色曲线)与同时段原始基线比值,计算修正系数 K(0.7 ~ 1.2)</li>
+     *   <li>原始基线 × K 得到预测基线;并查询今日 / 昨日实测曲线</li>
+     *   <li>组装 {@link SiteBaselineVO},dataSource 为 tsdb;TSDB 无数据时降级为 mock</li>
+     * </ol>
+     *
+     * @param siteId            站点 ID
+     * @param responseStartTime 响应开始时间
+     * @return 基线曲线及修正系数等信息
+     * @throws BusinessException 站点不存在、响应开始时间为空或格式无效
+     */
+    @Override
+    public SiteBaselineVO getSiteBaseline(Long siteId, String responseStartTime) {
+        VppSite site = resolveSite(siteId);
+        LocalDateTime startTime = parseResponseStartTime(responseStartTime);
+        LocalDate responseDate = startTime.toLocalDate();
+
+        List<String> deviceUuids = listControllableDeviceUuids(siteId);
+        SiteBaselineVO result = new SiteBaselineVO();
+        result.setSiteId(siteId);
+        result.setSiteName(site.getSiteName());
+        result.setResponseStartTime(startTime);
+
+        if (deviceUuids.isEmpty()) {
+            return buildMockResult(result, siteId, responseDate, startTime);
+        }
+
+        Set<LocalDate> excludeDates = loadResponseHistoryDates();
+        boolean workday = VppBaselineHelper.isWorkdayResponse(responseDate);
+        result.setResponseDayType(VppWorkdayHelper.resolveDayType(responseDate));
+        result.setResponseDayTypeLabel(VppWorkdayHelper.resolveDayTypeLabel(responseDate));
+        int requiredCount = workday
+                ? VppBaselineHelper.WEEKDAY_REFERENCE_COUNT
+                : VppBaselineHelper.NON_WEEKDAY_REFERENCE_COUNT;
+        List<LocalDate> referenceDates = VppBaselineHelper.selectReferenceDates(
+                responseDate, workday, excludeDates, requiredCount);
+        result.setReferenceDates(referenceDates.stream()
+                .map(VppBaselineHelper::formatDate)
+                .collect(Collectors.toList()));
+
+        //原始基线
+        Map<String, BigDecimal> originalBaselineByTime = vppTsdbQueryService.queryBaselineAvgByTimePoint(
+                deviceUuids,
+                referenceDates,
+                LocalTime.MIN,
+                LocalTime.of(23, 59, 59),
+                VppBaselineHelper.POWER_METRICS,
+                VppBaselineHelper.INTERVAL_MINUTES,
+                null);
+        if (originalBaselineByTime.isEmpty()) {
+            return buildMockResult(result, siteId, responseDate, startTime);
+        }
+
+        //历史日的响应开始时间前两个小时原始基线
+        LocalDateTime correctionWindowStart = startTime.minusHours(VppBaselineHelper.CORRECTION_HOURS);
+        Map<String, BigDecimal> correctionBaselineByTime = vppTsdbQueryService.queryBaselineAvgByTimePoint(
+                deviceUuids,
+                referenceDates,
+                correctionWindowStart.toLocalTime(),
+                startTime.toLocalTime(),
+                VppBaselineHelper.POWER_METRICS,
+                VppBaselineHelper.INTERVAL_MINUTES,
+                null);
+
+        LocalDate yesterday = responseDate.minusDays(1);
+        //响应开始时间日数据线
+        Map<String, BigDecimal> todayActualByTime = VppBaselineHelper.buildActualLoadByTimePoint(
+                vppTsdbQueryService.queryDeviceMetricHistory(
+                        deviceUuids, responseDate.atStartOfDay(), startTime, VppBaselineHelper.POWER_METRICS),
+                VppBaselineHelper.INTERVAL_MINUTES);
+        //响应开始时间日前一天数据线
+        Map<String, BigDecimal> yesterdayActualByTime = VppBaselineHelper.buildActualLoadByTimePoint(
+                vppTsdbQueryService.queryDeviceMetricHistory(
+                        deviceUuids, yesterday.atStartOfDay(), yesterday.atTime(23, 59, 59), VppBaselineHelper.POWER_METRICS),
+                VppBaselineHelper.INTERVAL_MINUTES);
+
+        //计算修正系数
+        BigDecimal correctionFactorK = VppBaselineHelper.calculateCorrectionFactor(
+                startTime, todayActualByTime, correctionBaselineByTime.isEmpty()
+                        ? originalBaselineByTime : correctionBaselineByTime);
+
+        //预测基线
+        Map<String, BigDecimal> predictedBaselineByTime = vppTsdbQueryService.queryBaselineAvgByTimePoint(
+                deviceUuids,
+                referenceDates,
+                LocalTime.MIN,
+                LocalTime.of(23, 59, 59),
+                VppBaselineHelper.POWER_METRICS,
+                VppBaselineHelper.INTERVAL_MINUTES,
+                correctionFactorK);
+
+        List<BaselinePointVO> points = VppBaselineHelper.buildBaselinePoints(
+                responseDate,
+                startTime,
+                predictedBaselineByTime.isEmpty() ? applyCorrection(originalBaselineByTime, correctionFactorK)
+                        : predictedBaselineByTime,
+                todayActualByTime,
+                yesterdayActualByTime);
+
+        result.setCorrectionFactorK(correctionFactorK);
+        result.setPoints(points);
+        result.setDataSource("tsdb");
+        return result;
+    }
+
+    private Map<String, BigDecimal> applyCorrection(Map<String, BigDecimal> originalBaselineByTime,
+                                                    BigDecimal correctionFactorK) {
+        Map<String, BigDecimal> corrected = new TreeMap<>();
+        for (Map.Entry<String, BigDecimal> entry : originalBaselineByTime.entrySet()) {
+            BigDecimal value = entry.getValue() != null ? entry.getValue() : BigDecimal.ZERO;
+            corrected.put(entry.getKey(), value.multiply(correctionFactorK).setScale(2, java.math.RoundingMode.HALF_UP));
+        }
+        return corrected;
+    }
+
+    private SiteBaselineVO buildMockResult(SiteBaselineVO result,
+                                           Long siteId,
+                                           LocalDate responseDate,
+                                           LocalDateTime responseStartTime) {
+        result.setReferenceDates(new ArrayList<>());
+        result.setResponseDayType(VppWorkdayHelper.resolveDayType(responseDate));
+        result.setResponseDayTypeLabel(VppWorkdayHelper.resolveDayTypeLabel(responseDate));
+        result.setCorrectionFactorK(VppBaselineHelper.mockCorrectionFactor(siteId, responseDate));
+        result.setPoints(VppBaselineHelper.buildMockBaselinePoints(siteId, responseDate, responseStartTime));
+        result.setDataSource("mock");
+        return result;
+    }
+
+    private List<String> listControllableDeviceUuids(Long siteId) {
+        List<VppResourcePoint> resources = resourcePointMapper.selectList(
+                new LambdaQueryWrapper<VppResourcePoint>()
+                        .eq(VppResourcePoint::getSiteId, siteId)
+                        .eq(VppResourcePoint::getIsControl, 1)
+                        .eq(VppResourcePoint::getDeleteFlag, VppAuditHelper.NOT_DELETED));
+        if (CollectionUtils.isEmpty(resources)) {
+            return new ArrayList<>();
+        }
+
+        Set<Long> deviceIds = resources.stream()
+                .map(VppResourcePoint::getDeviceId)
+                .filter(Objects::nonNull)
+                .collect(Collectors.toSet());
+        if (deviceIds.isEmpty()) {
+            return new ArrayList<>();
+        }
+
+        List<VppDevice> devices = deviceMapper.selectBatchIds(deviceIds).stream()
+                .filter(device -> !VppAuditHelper.isDeleted(device.getDeleteFlag()))
+                .collect(Collectors.toList());
+
+        Set<String> uuids = new LinkedHashSet<>();
+        for (VppDevice device : devices) {
+            if (StringUtils.hasText(device.getDeviceUuid())) {
+                uuids.add(device.getDeviceUuid().trim());
+            }
+        }
+        return new ArrayList<>(uuids);
+    }
+
+    private Set<LocalDate> loadResponseHistoryDates() {
+        List<VppDrEvent> events = drEventMapper.selectList(
+                new LambdaQueryWrapper<VppDrEvent>()
+                        .eq(VppDrEvent::getEventStatus, EVENT_STATUS_ENDED)
+                        .eq(VppDrEvent::getDeleteFlag, VppAuditHelper.NOT_DELETED));
+        Set<LocalDate> dates = new HashSet<>();
+        for (VppDrEvent event : events) {
+            if (event.getStartTime() != null) {
+                dates.add(event.getStartTime().toLocalDate());
+            }
+        }
+        return dates;
+    }
+
+    private VppSite resolveSite(Long siteId) {
+        if (siteId == null) {
+            throw new BusinessException("站点不存在");
+        }
+        VppSite site = siteMapper.selectById(siteId);
+        if (site == null || VppAuditHelper.isDeleted(site.getDeleteFlag())) {
+            throw new BusinessException("站点不存在");
+        }
+        return site;
+    }
+
+    private LocalDateTime parseResponseStartTime(String responseStartTime) {
+        if (!StringUtils.hasText(responseStartTime)) {
+            throw new BusinessException("响应开始时间不能为空");
+        }
+        try {
+            return LocalDateTime.parse(responseStartTime.trim(), DATE_TIME_FMT);
+        } catch (DateTimeParseException ex) {
+            throw new BusinessException("响应开始时间格式无效,请使用 yyyy-MM-dd HH:mm:ss");
+        }
+    }
+}

+ 0 - 7
service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/service/impl/VppDeviceServiceImpl.java

@@ -145,9 +145,6 @@ public class VppDeviceServiceImpl implements VppDeviceService {
         if (params.get("runStatus") != null) {
             wrapper.eq(VppDevice::getRunStatus, Integer.parseInt(params.get("runStatus").toString()));
         }
-        if (params.get("streetTown") != null) {
-            wrapper.eq(VppDevice::getStreetTown, params.get("streetTown").toString());
-        }
         return wrapper;
     }
 
@@ -190,10 +187,6 @@ public class VppDeviceServiceImpl implements VppDeviceService {
         device.setFirmwareVersion(request.getFirmwareVersion());
         device.setGatewayId(request.getGatewayId());
         device.setRemark(request.getRemark());
-        device.setAccountNo(request.getAccountNo());
-        if(request.getStreetTown() != null){
-            device.setStreetTown(request.getStreetTown());
-        }
     }
 
     private CommonPage<VppDevice> toCommonPage(Page<VppDevice> page) {

+ 7 - 0
service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/service/impl/VppSiteServiceImpl.java

@@ -178,6 +178,9 @@ public class VppSiteServiceImpl implements VppSiteService {
             if (params.get("district") != null) {
                 wrapper.eq(VppSite::getDistrict, params.get("district").toString());
             }
+            if (params.get("streetTown") != null) {
+                wrapper.eq(VppSite::getStreetTown, params.get("streetTown").toString());
+            }
         }
         return wrapper;
     }
@@ -302,6 +305,10 @@ public class VppSiteServiceImpl implements VppSiteService {
         site.setResponsePriority(request.getResponsePriority());
         site.setUnResourceId(request.getUnResourceId());
         site.setRemark(request.getRemark());
+        site.setAccountNo(request.getAccountNo());
+        if(request.getStreetTown() != null) {
+            site.setStreetTown(request.getStreetTown());
+        }
     }
 
     private void applySiteConfig(VppSiteConfig config, SiteConfigRequest request) {

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

@@ -1,170 +1,196 @@
-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);
-    }
-}
+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 com.usky.vpp.util.VppBaselineHelper;

+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.LocalDate;

+import java.time.LocalDateTime;

+import java.time.LocalTime;

+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();

+        }

+    }

+

+    @Override

+    public Map<String, BigDecimal> queryBaselineAvgByTimePoint(List<String> deviceUuids,

+                                                              List<LocalDate> referenceDates,

+                                                              LocalTime periodStart,

+                                                              LocalTime periodEnd,

+                                                              List<String> metrics,

+                                                              Integer intervalMinutes,

+                                                              BigDecimal correctionFactor) {

+        if (CollectionUtils.isEmpty(deviceUuids) || CollectionUtils.isEmpty(referenceDates)) {

+            return Collections.emptyMap();

+        }

+        int interval = intervalMinutes != null && intervalMinutes > 0

+                ? intervalMinutes : VppBaselineHelper.INTERVAL_MINUTES;

+        return VppBaselineHelper.aggregateBaselineAvgByTimePoint(

+                referenceDates,

+                periodStart,

+                periodEnd,

+                interval,

+                correctionFactor,

+                (start, end) -> queryDeviceMetricHistory(deviceUuids, start, end, metrics));

+    }

+

+    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/BaselinePointVO.java

@@ -0,0 +1,21 @@
+package com.usky.vpp.service.vo;
+
+import lombok.Data;
+
+import java.math.BigDecimal;
+
+/**
+ * 基线管理曲线 - 单时间点
+ */
+@Data
+public class BaselinePointVO {
+
+    /** 时刻 HH:mm */
+    private String time;
+    /** 预测基线 kW(黄色曲线) */
+    private BigDecimal predictedBaselineKw;
+    /** 今日实际 kW(绿色曲线) */
+    private BigDecimal todayActualKw;
+    /** 昨日实际 kW(蓝色曲线) */
+    private BigDecimal yesterdayActualKw;
+}

+ 0 - 2
service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/service/vo/DeviceRequest.java

@@ -21,8 +21,6 @@ public class DeviceRequest {
     private String firmwareVersion;
     private String gatewayId;
     private String remark;
-    private String accountNo;
-    private String streetTown;
 
     private String productCode;
 }

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

@@ -0,0 +1,31 @@
+package com.usky.vpp.service.vo;
+
+import lombok.Data;
+
+import java.math.BigDecimal;
+import java.time.LocalDateTime;
+import java.util.List;
+
+/**
+ * 站点基线管理查询结果
+ */
+@Data
+public class SiteBaselineVO {
+
+    private Long siteId;
+    private String siteName;
+    /** 响应开始时间 */
+    private LocalDateTime responseStartTime;
+    /** 日内修正系数 K(0.7 ~ 1.2) */
+    private BigDecimal correctionFactorK;
+    /** 典型历史日(yyyy-MM-dd) */
+    private List<String> referenceDates;
+    /** 响应日类型:WORKDAY / NON_WORKDAY */
+    private String responseDayType;
+    /** 响应日类型中文 */
+    private String responseDayTypeLabel;
+    /** 数据来源:tsdb / mock */
+    private String dataSource;
+    /** 全天曲线(整点) */
+    private List<BaselinePointVO> points;
+}

+ 2 - 0
service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/service/vo/SiteRequest.java

@@ -25,4 +25,6 @@ public class SiteRequest {
     private Integer responsePriority;
     private String unResourceId;
     private String remark;
+    private String accountNo;
+    private String streetTown;
 }

+ 393 - 0
service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/util/VppBaselineHelper.java

@@ -0,0 +1,393 @@
+package com.usky.vpp.util;
+
+import com.usky.vpp.constant.VppTsdbConstants;
+import com.usky.vpp.service.vo.BaselinePointVO;
+
+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.Collections;
+import java.util.HashSet;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.TreeMap;
+import java.util.function.BiFunction;
+
+/**
+ * 基线管理计算辅助(典型日筛选、原始基线、修正系数、曲线构建)
+ */
+public final class VppBaselineHelper {
+
+    public static final int WEEKDAY_REFERENCE_COUNT = 5;
+    public static final int NON_WEEKDAY_REFERENCE_COUNT = 3;
+    public static final int CORRECTION_HOURS = 2;
+    public static final int INTERVAL_MINUTES = 5;
+    public static final BigDecimal K_MIN = new BigDecimal("0.7");
+    public static final BigDecimal K_MAX = new BigDecimal("1.2");
+    public static final BigDecimal K_DEFAULT = BigDecimal.ONE;
+
+    /** 总有功功率指标(文档 p + 存量字段兼容) */
+    public static final List<String> POWER_METRICS;
+
+    private static final DateTimeFormatter TIME_FMT = DateTimeFormatter.ofPattern("HH:mm");
+    private static final DateTimeFormatter TIME_WITH_SEC_FMT = DateTimeFormatter.ofPattern("HH:mm:ss");
+    private static final DateTimeFormatter DATE_FMT = DateTimeFormatter.ofPattern("yyyy-MM-dd");
+
+    static {
+        List<String> metrics = new ArrayList<>();
+        metrics.add("p");
+        metrics.addAll(VppTsdbConstants.ACTIVE_POWER_METRICS);
+        POWER_METRICS = Collections.unmodifiableList(metrics);
+    }
+
+    private VppBaselineHelper() {
+    }
+
+    /**
+     * 响应日是否为工作日(Hutool 法定节假日 + 调休规则)
+     */
+    public static boolean isWorkdayResponse(LocalDate date) {
+        return VppWorkdayHelper.isWorkday(date);
+    }
+
+    /**
+     * @deprecated 使用 {@link #isWorkdayResponse(LocalDate)}
+     */
+    @Deprecated
+    public static boolean isWeekday(LocalDate date) {
+        return isWorkdayResponse(date);
+    }
+
+    /**
+     * 选取典型历史日:工作日取前 5 个工作日,非工作日取前 3 个非工作日;
+     * 仅剔除响应当日与历史响应日(不含节假日前后日期)。
+     */
+    public static List<LocalDate> selectReferenceDates(LocalDate responseDate,
+                                                       boolean workday,
+                                                       Set<LocalDate> excludeDates,
+                                                       int requiredCount) {
+        Set<LocalDate> excludes = new HashSet<>(excludeDates);
+        excludes.add(responseDate);
+
+        List<LocalDate> result = new ArrayList<>();
+        LocalDate cursor = responseDate.minusDays(1);
+        int guard = 0;
+        while (result.size() < requiredCount && guard < 400) {
+            if (excludes.contains(cursor)) {
+                cursor = cursor.minusDays(1);
+                guard++;
+                continue;
+            }
+            if (VppWorkdayHelper.isWorkday(cursor) == workday) {
+                result.add(cursor);
+            }
+            cursor = cursor.minusDays(1);
+            guard++;
+        }
+        return result;
+    }
+
+    /**
+     * 多历史日同时段各时间点负荷求平均(先按日汇总设备负荷,再跨日取均值)。
+     * <p>通过 historyLoader 按日调用 TSDB {@code queryHistoryDeviceData} 获取原始时序后在本模块聚合。</p>
+     */
+    public static Map<String, BigDecimal> aggregateBaselineAvgByTimePoint(
+            List<LocalDate> referenceDates,
+            LocalTime periodStart,
+            LocalTime periodEnd,
+            int intervalMinutes,
+            BigDecimal correctionFactor,
+            BiFunction<LocalDateTime, LocalDateTime, Map<String, Map<String, TreeMap<LocalDateTime, BigDecimal>>>> historyLoader) {
+        if (referenceDates == null || referenceDates.isEmpty() || historyLoader == null) {
+            return Collections.emptyMap();
+        }
+
+        LocalTime dayStart = periodStart != null ? periodStart : LocalTime.MIN;
+        LocalTime dayEnd = periodEnd != null ? periodEnd : LocalTime.of(23, 59, 59);
+        int interval = intervalMinutes > 0 ? intervalMinutes : INTERVAL_MINUTES;
+
+        Map<String, List<BigDecimal>> samplesByTime = new TreeMap<>();
+        for (LocalDate date : referenceDates) {
+            LocalDateTime start = LocalDateTime.of(date, dayStart);
+            LocalDateTime end = LocalDateTime.of(date, dayEnd);
+            if (end.isBefore(start)) {
+                continue;
+            }
+            Map<String, Map<String, TreeMap<LocalDateTime, BigDecimal>>> dayData = historyLoader.apply(start, end);
+            Map<String, BigDecimal> dayBuckets = buildActualLoadByTimePoint(dayData, interval);
+            if (dayBuckets.isEmpty()) {
+                continue;
+            }
+            for (Map.Entry<String, BigDecimal> entry : dayBuckets.entrySet()) {
+                samplesByTime.computeIfAbsent(entry.getKey(), key -> new ArrayList<>()).add(entry.getValue());
+            }
+        }
+
+        Map<String, BigDecimal> result = new LinkedHashMap<>();
+        for (Map.Entry<String, List<BigDecimal>> entry : samplesByTime.entrySet()) {
+            BigDecimal avg = average(entry.getValue());
+            if (correctionFactor != null) {
+                avg = avg.multiply(correctionFactor);
+            }
+            result.put(entry.getKey(), scale(avg));
+        }
+        return result;
+    }
+
+    /**
+     * 将设备历史时序聚合为 HH:mm 时间点总负荷。
+     */
+    public static Map<String, BigDecimal> buildActualLoadByTimePoint(
+            Map<String, Map<String, TreeMap<LocalDateTime, BigDecimal>>> deviceData,
+            int intervalMinutes) {
+        if (deviceData == null || deviceData.isEmpty()) {
+            return Collections.emptyMap();
+        }
+
+        Map<String, List<BigDecimal>> samples = new TreeMap<>();
+        for (Map<String, TreeMap<LocalDateTime, BigDecimal>> metricMap : deviceData.values()) {
+            TreeMap<LocalDateTime, BigDecimal> series = pickSeries(metricMap);
+            if (series == null || series.isEmpty()) {
+                continue;
+            }
+            Map<String, List<BigDecimal>> deviceBuckets = bucketSeries(series, intervalMinutes);
+            for (Map.Entry<String, List<BigDecimal>> entry : deviceBuckets.entrySet()) {
+                BigDecimal deviceAvg = average(entry.getValue());
+                samples.computeIfAbsent(entry.getKey(), key -> new ArrayList<>()).add(deviceAvg);
+            }
+        }
+
+        Map<String, BigDecimal> result = new LinkedHashMap<>();
+        for (Map.Entry<String, List<BigDecimal>> entry : samples.entrySet()) {
+            result.put(entry.getKey(), scale(sum(entry.getValue())));
+        }
+        return result;
+    }
+
+    /**
+     * 计算日内修正系数 K = 响应开始前 2 小时今日实测 / 同时段原始基线,并限制在 [0.7, 1.2]。
+     */
+    public static BigDecimal calculateCorrectionFactor(LocalDateTime responseStartTime,
+                                                       Map<String, BigDecimal> todayActualByTime,
+                                                       Map<String, BigDecimal> originalBaselineByTime) {
+        LocalDateTime windowStart = responseStartTime.minusHours(CORRECTION_HOURS);
+        BigDecimal todayTotal = sumValuesInWindow(todayActualByTime, windowStart, responseStartTime);
+        BigDecimal baselineTotal = sumValuesInWindow(originalBaselineByTime, windowStart, responseStartTime);
+        if (baselineTotal == null || baselineTotal.compareTo(BigDecimal.ZERO) <= 0) {
+            return K_DEFAULT;
+        }
+        if (todayTotal == null || todayTotal.compareTo(BigDecimal.ZERO) <= 0) {
+            return K_DEFAULT;
+        }
+        BigDecimal k = todayTotal.divide(baselineTotal, 6, RoundingMode.HALF_UP);
+        if (k.compareTo(K_MIN) < 0) {
+            return K_MIN;
+        }
+        if (k.compareTo(K_MAX) > 0) {
+            return K_MAX;
+        }
+        return k.setScale(4, RoundingMode.HALF_UP);
+    }
+
+    public static List<BaselinePointVO> buildBaselinePoints(LocalDate responseDate,
+                                                            LocalDateTime responseStartTime,
+                                                            Map<String, BigDecimal> predictedBaselineByTime,
+                                                            Map<String, BigDecimal> todayActualByTime,
+                                                            Map<String, BigDecimal> yesterdayActualByTime) {
+        List<BaselinePointVO> points = new ArrayList<>();
+        for (Map.Entry<String, BigDecimal> entry : predictedBaselineByTime.entrySet()) {
+            String timeKey = entry.getKey();
+            LocalTime time = parseTimeKey(timeKey);
+            LocalDateTime pointTime = LocalDateTime.of(responseDate, time);
+
+            BaselinePointVO point = new BaselinePointVO();
+            point.setTime(timeKey);
+            point.setPredictedBaselineKw(scale(entry.getValue()));
+
+            if (!pointTime.isAfter(responseStartTime)) {
+                point.setTodayActualKw(scale(todayActualByTime.get(timeKey)));
+            }
+            point.setYesterdayActualKw(scale(yesterdayActualByTime.get(timeKey)));
+            points.add(point);
+        }
+        return points;
+    }
+
+    public static boolean hasTsdbPowerData(Map<String, Map<String, TreeMap<LocalDateTime, BigDecimal>>> deviceMetricData) {
+        return VppCapabilityEvalHelper.hasTsdbPowerData(deviceMetricData, POWER_METRICS);
+    }
+
+    public static List<BaselinePointVO> buildMockBaselinePoints(Long siteId,
+                                                                LocalDate responseDate,
+                                                                LocalDateTime responseStartTime) {
+        List<BaselinePointVO> points = new ArrayList<>();
+        long seed = (siteId != null ? siteId : 0L) + responseDate.toEpochDay();
+        for (int minute = 0; minute < 1440; minute += INTERVAL_MINUTES) {
+            LocalTime time = LocalTime.of(minute / 60, minute % 60);
+            String timeKey = time.format(TIME_FMT);
+            double hourFactor = mockHourFactor(time);
+            double noise = 0.9 + pseudoRandom(seed, minute, 0) * 0.2;
+
+            BigDecimal yesterday = scale(BigDecimal.valueOf(900 * hourFactor * noise));
+            BigDecimal original = yesterday.multiply(BigDecimal.valueOf(0.98 + pseudoRandom(seed, minute, 1) * 0.04))
+                    .setScale(2, RoundingMode.HALF_UP);
+            BigDecimal predicted = original.multiply(BigDecimal.valueOf(1.03)).setScale(2, RoundingMode.HALF_UP);
+
+            BaselinePointVO point = new BaselinePointVO();
+            point.setTime(timeKey);
+            point.setPredictedBaselineKw(predicted);
+            point.setYesterdayActualKw(yesterday);
+            if (!responseDate.atTime(time).isAfter(responseStartTime)) {
+                point.setTodayActualKw(scale(original.multiply(BigDecimal.valueOf(1.01 + pseudoRandom(seed, minute, 2) * 0.05))));
+            }
+            points.add(point);
+        }
+        return points;
+    }
+
+    public static BigDecimal mockCorrectionFactor(Long siteId, LocalDate responseDate) {
+        long seed = (siteId != null ? siteId : 0L) + responseDate.toEpochDay();
+        double raw = 0.85 + pseudoRandom(seed, 99, 3) * 0.25;
+        BigDecimal k = BigDecimal.valueOf(raw).setScale(4, RoundingMode.HALF_UP);
+        if (k.compareTo(K_MIN) < 0) {
+            return K_MIN;
+        }
+        if (k.compareTo(K_MAX) > 0) {
+            return K_MAX;
+        }
+        return k;
+    }
+
+    public static String formatDate(LocalDate date) {
+        return date.format(DATE_FMT);
+    }
+
+    private static Map<String, List<BigDecimal>> bucketSeries(TreeMap<LocalDateTime, BigDecimal> series,
+                                                              int intervalMinutes) {
+        Map<String, List<BigDecimal>> buckets = new TreeMap<>();
+        for (Map.Entry<LocalDateTime, BigDecimal> entry : series.entrySet()) {
+            if (entry.getValue() == null) {
+                continue;
+            }
+            String bucket = toBucketKey(entry.getKey(), intervalMinutes);
+            buckets.computeIfAbsent(bucket, key -> new ArrayList<>()).add(entry.getValue());
+        }
+        return buckets;
+    }
+
+    private static String toBucketKey(LocalDateTime timestamp, int intervalMinutes) {
+        int totalMinutes = timestamp.getHour() * 60 + timestamp.getMinute();
+        int bucketMinutes = (totalMinutes / intervalMinutes) * intervalMinutes;
+        return LocalTime.of(bucketMinutes / 60, bucketMinutes % 60).format(TIME_FMT);
+    }
+
+    private static LocalTime parseTimeKey(String timeKey) {
+        if (timeKey.length() == 5) {
+            return LocalTime.parse(timeKey + ":00", TIME_WITH_SEC_FMT);
+        }
+        return LocalTime.parse(timeKey, TIME_WITH_SEC_FMT);
+    }
+
+    private static BigDecimal sumValuesInWindow(Map<String, BigDecimal> valuesByTime,
+                                                LocalDateTime windowStart,
+                                                LocalDateTime windowEnd) {
+        if (valuesByTime == null || valuesByTime.isEmpty()) {
+            return null;
+        }
+        BigDecimal total = BigDecimal.ZERO;
+        int count = 0;
+        LocalDate date = windowStart.toLocalDate();
+        for (Map.Entry<String, BigDecimal> entry : valuesByTime.entrySet()) {
+            LocalDateTime pointTime = LocalDateTime.of(date, parseTimeKey(entry.getKey()));
+            if (pointTime.isBefore(windowStart) || pointTime.isAfter(windowEnd)) {
+                continue;
+            }
+            if (entry.getValue() != null) {
+                total = total.add(entry.getValue());
+                count++;
+            }
+        }
+        return count == 0 ? null : total;
+    }
+
+    private static TreeMap<LocalDateTime, BigDecimal> pickSeries(Map<String, TreeMap<LocalDateTime, BigDecimal>> metricMap) {
+        if (metricMap == null || metricMap.isEmpty()) {
+            return null;
+        }
+        for (String metric : POWER_METRICS) {
+            TreeMap<LocalDateTime, BigDecimal> series = metricMap.get(metric);
+            if (series != null && !series.isEmpty()) {
+                return series;
+            }
+        }
+        for (TreeMap<LocalDateTime, BigDecimal> series : metricMap.values()) {
+            if (series != null && !series.isEmpty()) {
+                return series;
+            }
+        }
+        return null;
+    }
+
+    private static BigDecimal sum(List<BigDecimal> values) {
+        if (values == null || values.isEmpty()) {
+            return BigDecimal.ZERO;
+        }
+        BigDecimal total = BigDecimal.ZERO;
+        for (BigDecimal value : values) {
+            if (value != null) {
+                total = total.add(value);
+            }
+        }
+        return total;
+    }
+
+    private static BigDecimal average(List<BigDecimal> values) {
+        if (values == null || values.isEmpty()) {
+            return BigDecimal.ZERO;
+        }
+        return sum(values).divide(BigDecimal.valueOf(values.size()), 4, RoundingMode.HALF_UP);
+    }
+
+    private static BigDecimal scale(BigDecimal value) {
+        if (value == null) {
+            return null;
+        }
+        return value.setScale(2, RoundingMode.HALF_UP);
+    }
+
+    private static double mockHourFactor(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 pseudoRandom(long seed, int minuteOfDay, int seriesSalt) {
+        long mixed = seed * 31L + minuteOfDay * 17L + seriesSalt * 13L;
+        return (mixed % 1000) / 1000.0;
+    }
+}

+ 47 - 0
service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/util/VppWorkdayHelper.java

@@ -0,0 +1,47 @@
+package com.usky.vpp.util;
+
+import cn.hutool.core.date.DateUtil;
+
+import java.time.LocalDate;
+import java.util.Date;
+
+/**
+ * 工作日 / 非工作日判定(Hutool 法定节假日 + 调休)
+ */
+public final class VppWorkdayHelper {
+
+    public static final String DAY_TYPE_WORKDAY = "WORKDAY";
+    public static final String DAY_TYPE_NON_WORKDAY = "NON_WORKDAY";
+
+    private VppWorkdayHelper() {
+    }
+
+    /**
+     * 是否为工作日(含调休上班,排除法定节假日与正常周末)
+     */
+    public static boolean isWorkday(LocalDate date) {
+        if (date == null) {
+            return false;
+        }
+        return DateUtil.isWorkDay(toDate(date));
+    }
+
+    /**
+     * 是否为非工作日(周末或法定节假日,不含调休上班)
+     */
+    public static boolean isNonWorkday(LocalDate date) {
+        return !isWorkday(date);
+    }
+
+    public static String resolveDayType(LocalDate date) {
+        return isWorkday(date) ? DAY_TYPE_WORKDAY : DAY_TYPE_NON_WORKDAY;
+    }
+
+    public static String resolveDayTypeLabel(LocalDate date) {
+        return isWorkday(date) ? "工作日" : "非工作日";
+    }
+
+    private static Date toDate(LocalDate date) {
+        return DateUtil.date(date);
+    }
+}