Просмотр исходного кода

新增补贴费用预测&根据新需求调整代码

fuyuchuan 4 дней назад
Родитель
Сommit
65d45ccb02
26 измененных файлов с 1307 добавлено и 10 удалено
  1. 9 0
      service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/constant/VppTsdbConstants.java
  2. 24 0
      service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/controller/web/DrController.java
  3. 11 2
      service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/domain/VppContract.java
  4. 10 0
      service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/domain/VppDrInvitation.java
  5. 79 0
      service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/domain/VppDrSubsidyPrediction.java
  6. 46 0
      service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/enums/DeviceTypeMetricEnum.java
  7. 40 0
      service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/enums/ElectricSeasonEnum.java
  8. 11 0
      service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/enums/ElectricUserTypeEnum.java
  9. 58 0
      service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/enums/TimeSegmentEnum.java
  10. 37 0
      service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/job/VppSettlementMonthlyScheduler.java
  11. 12 0
      service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/mapper/VppDrSubsidyPredictionMapper.java
  12. 29 0
      service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/service/VppDrSubsidyPredictionService.java
  13. 3 0
      service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/service/VppSettlementService.java
  14. 23 0
      service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/service/VppSettlementSyncService.java
  15. 9 4
      service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/service/impl/VppContractServiceImpl.java
  16. 16 0
      service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/service/impl/VppDrServiceImpl.java
  17. 212 0
      service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/service/impl/VppDrSubsidyPredictionServiceImpl.java
  18. 382 0
      service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/service/impl/VppSettlementSyncServiceImpl.java
  19. 90 0
      service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/service/vo/DrSubsidyPredictionVO.java
  20. 7 2
      service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/service/vo/VppContractRequestVO.java
  21. 7 2
      service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/service/vo/VppContractResponseVO.java
  22. 59 0
      service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/util/ElectricFeeCalcUtil.java
  23. 65 0
      service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/util/ElectricTimeUtil.java
  24. 5 0
      service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/util/VppAuditHelper.java
  25. 59 0
      service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/util/VppEnergyUsageHelper.java
  26. 4 0
      service-vpp/service-vpp-biz/src/main/resources/bootstrap.yml

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

@@ -45,5 +45,14 @@ public final class VppTsdbConstants {
     /** 正向总有功电能(用电累计) */
     public static final String METRIC_EPP = "epp";
 
+    /** 反向总有功电能(发电累计) */
+    public static final String METRIC_EPN = "epn";
+
+    /** 尖/峰/平/谷 正向有功电度 */
+    public static final String METRIC_EPP1 = "epp1";
+    public static final String METRIC_EPP2 = "epp2";
+    public static final String METRIC_EPP3 = "epp3";
+    public static final String METRIC_EPP4 = "epp4";
+
     public static final String TIME_FORMAT = "yyyy-MM-dd HH:mm:ss";
 }

+ 24 - 0
service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/controller/web/DrController.java

@@ -7,6 +7,7 @@ import com.usky.vpp.domain.VppDrEvaluation;
 import com.usky.vpp.domain.VppDrStrategy;
 import com.usky.vpp.service.VppDrInvitationService;
 import com.usky.vpp.service.VppDrService;
+import com.usky.vpp.service.VppDrSubsidyPredictionService;
 import com.usky.vpp.service.vo.DrClearingRequest;
 import com.usky.vpp.service.vo.DrEventDetailVO;
 import com.usky.vpp.service.vo.DrEventRequest;
@@ -18,6 +19,7 @@ import com.usky.vpp.service.vo.DrInvitationVO;
 import com.usky.vpp.service.vo.DrParticipateRequest;
 import com.usky.vpp.service.vo.DrStrategyRequest;
 import com.usky.vpp.service.vo.DrStrategyVO;
+import com.usky.vpp.service.vo.DrSubsidyPredictionVO;
 import org.springframework.beans.factory.annotation.Autowired;
 import org.springframework.web.bind.annotation.*;
 
@@ -35,6 +37,8 @@ public class DrController {
     private VppDrService vppDrService;
     @Autowired
     private VppDrInvitationService vppDrInvitationService;
+    @Autowired
+    private VppDrSubsidyPredictionService vppDrSubsidyPredictionService;
 
     @GetMapping(value = "/event")
     public ApiResult<CommonPage<VppDrEvent>> pageEvent(@RequestParam(required = false) Map<String, Object> params) {
@@ -173,4 +177,24 @@ public class DrController {
                                                        @RequestBody DrInvitationReplyRequest body) {
         return ApiResult.success(vppDrInvitationService.replyInvitation(id, body));
     }
+
+    // ==================== 补贴金额预测 ====================
+
+    /**
+     * 分页查询补贴金额预测
+     * siteName  站点名称(模糊匹配)
+     * startTime 响应开始时间起始
+     * endTime   响应开始时间截止
+     * current   页码
+     * size      页大小
+     */
+    @GetMapping(value = "/subsidyPrediction")
+    public ApiResult<CommonPage<DrSubsidyPredictionVO>> pageSubsidyPrediction(
+            @RequestParam(value = "siteName", required = false) String siteName,
+            @RequestParam(value = "startTime", required = false) String startTime,
+            @RequestParam(value = "endTime", required = false) String endTime,
+            @RequestParam(value = "current", required = false, defaultValue = "1") Integer current,
+            @RequestParam(value = "size", required = false, defaultValue = "20") Integer size) {
+        return ApiResult.success(vppDrSubsidyPredictionService.page(siteName, startTime, endTime, current, size));
+    }
 }

+ 11 - 2
service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/domain/VppContract.java

@@ -72,8 +72,17 @@ public class VppContract implements Serializable {
     @TableField("file_url")
     private String fileUrl;
 
-    @TableField("share_ratio")
-    private BigDecimal shareRatio;
+    /**
+     * 客户比例
+    */
+    @TableField("customer_ratio")
+    private BigDecimal customerRatio;
+
+    /**
+     * 运营商比例
+     */
+    @TableField("operator_ratio")
+    private BigDecimal operatorRatio;
 
     @TableField("price_json")
     private String priceJson;

+ 10 - 0
service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/domain/VppDrInvitation.java

@@ -73,4 +73,14 @@ public class VppDrInvitation implements Serializable {
     private Integer deleteFlag;
     @TableField("deleted_at")
     private LocalDateTime deletedAt;
+    @TableField("site_id")
+    private long site_id;
+    @TableField("actual_cleared_capacity_kw")
+    private BigDecimal actual_cleared_capacity_kw;
+    @TableField("estimated_response_capacity_kw")
+    private BigDecimal estimated_response_capacity_kw;
+    @TableField("actual_response_capacity_kw")
+    private BigDecimal actual_response_capacity_kw;
+    @TableField("is_winning_bid")
+    private int is_winning_bid;
 }

+ 79 - 0
service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/domain/VppDrSubsidyPrediction.java

@@ -0,0 +1,79 @@
+package com.usky.vpp.domain;
+
+import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.TableField;
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableName;
+import com.fasterxml.jackson.annotation.JsonFormat;
+import lombok.Data;
+import lombok.EqualsAndHashCode;
+
+import java.io.Serializable;
+import java.math.BigDecimal;
+import java.time.LocalDateTime;
+
+/**
+ * vpp_dr_subsidy_prediction - 需求响应补贴金额预测
+ */
+@Data
+@EqualsAndHashCode(callSuper = false)
+@TableName("vpp_dr_subsidy_prediction")
+public class VppDrSubsidyPrediction implements Serializable {
+
+    private static final long serialVersionUID = 1L;
+
+    @TableId(value = "id", type = IdType.AUTO)
+    private Long id;
+
+    @TableField("invitation_id")
+    private Long invitationId;
+
+    @TableField("dr_event_id")
+    private Long drEventId;
+
+    @TableField("site_id")
+    private Long siteId;
+
+    @TableField("site_name")
+    private String siteName;
+
+    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
+    @TableField("event_start_time")
+    private LocalDateTime eventStartTime;
+
+    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
+    @TableField("event_end_time")
+    private LocalDateTime eventEndTime;
+
+    @TableField("response_duration_min")
+    private Integer responseDurationMin;
+
+    @TableField("subsidy_price")
+    private BigDecimal subsidyPrice;
+
+    @TableField("estimated_capacity_kw")
+    private BigDecimal estimatedCapacityKw;
+
+    @TableField("cleared_capacity_kw")
+    private BigDecimal clearedCapacityKw;
+
+    @TableField("completion_rate")
+    private BigDecimal completionRate;
+
+    @TableField("deviation_rate")
+    private BigDecimal deviationRate;
+
+    @TableField("estimated_subsidy_amount")
+    private BigDecimal estimatedSubsidyAmount;
+
+    @TableField("tenant_id")
+    private Integer tenantId;
+
+    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
+    @TableField("create_time")
+    private LocalDateTime createTime;
+
+    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
+    @TableField("update_time")
+    private LocalDateTime updateTime;
+}

+ 46 - 0
service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/enums/DeviceTypeMetricEnum.java

@@ -0,0 +1,46 @@
+package com.usky.vpp.enums;
+
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.List;
+import java.util.Optional;
+
+/**
+ * 设备类型与时序电量属性映射(用于月度电量核算)
+ */
+public enum DeviceTypeMetricEnum {
+
+    /**
+     * 充电桩 product/device_type = 809
+     */
+    EV_CHARGER_809(
+            "809",
+            Arrays.asList("epp1", "epp2", "epp3", "epp4", "epn1", "epn2", "epn3", "epn4", "epp", "epn")
+    );
+
+    private final String deviceType;
+    private final List<String> metrics;
+
+    DeviceTypeMetricEnum(String deviceType, List<String> metrics) {
+        this.deviceType = deviceType;
+        this.metrics = Collections.unmodifiableList(metrics);
+    }
+
+    public String getDeviceType() {
+        return deviceType;
+    }
+
+    public List<String> getMetrics() {
+        return metrics;
+    }
+
+    public static Optional<DeviceTypeMetricEnum> ofDeviceType(String deviceType) {
+        if (deviceType == null || deviceType.trim().isEmpty()) {
+            return Optional.empty();
+        }
+        String key = deviceType.trim();
+        return Arrays.stream(values())
+                .filter(item -> item.deviceType.equalsIgnoreCase(key))
+                .findFirst();
+    }
+}

+ 40 - 0
service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/enums/ElectricSeasonEnum.java

@@ -0,0 +1,40 @@
+package com.usky.vpp.enums;
+
+import java.time.Month;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.EnumSet;
+import java.util.Set;
+import java.util.stream.Collectors;
+
+/**
+ * 上海分时电价季节分类(沪发改价管〔2022〕50号)
+ */
+public enum ElectricSeasonEnum {
+
+    /** 夏季:7,8,9月 */
+    SUMMER(EnumSet.of(Month.JULY, Month.AUGUST, Month.SEPTEMBER)),
+    /** 冬季尖峰月:1,12月 */
+    WINTER_SPIKE(EnumSet.of(Month.JANUARY, Month.DECEMBER)),
+    /** 普通月份:3,4,5,6,10,11月 */
+    NORMAL(EnumSet.of(Month.MARCH, Month.APRIL, Month.MAY, Month.JUNE, Month.OCTOBER, Month.NOVEMBER));
+
+    private final Set<Integer> monthNumbers;
+
+    ElectricSeasonEnum(Set<Month> months) {
+        this.monthNumbers = Collections.unmodifiableSet(months.stream()
+                .map(Month::getValue)
+                .collect(Collectors.toSet()));
+    }
+
+    public static ElectricSeasonEnum getByMonth(int month) {
+        return Arrays.stream(values())
+                .filter(season -> season.monthNumbers.contains(month))
+                .findFirst()
+                .orElseThrow(() -> new IllegalArgumentException("非法月份:" + month));
+    }
+
+    public Set<Integer> getMonthNumbers() {
+        return monthNumbers;
+    }
+}

+ 11 - 0
service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/enums/ElectricUserTypeEnum.java

@@ -0,0 +1,11 @@
+package com.usky.vpp.enums;
+
+/**
+ * 工商业电价用户类型
+ */
+public enum ElectricUserTypeEnum {
+    /** 两部制:尖/峰/平/谷 */
+    TWO_PART,
+    /** 单一制:仅峰/谷 */
+    SINGLE_PART
+}

+ 58 - 0
service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/enums/TimeSegmentEnum.java

@@ -0,0 +1,58 @@
+package com.usky.vpp.enums;
+
+import java.math.BigDecimal;
+import java.util.Arrays;
+
+/**
+ * 分时时段:尖峰、高峰、平段、低谷
+ */
+public enum TimeSegmentEnum {
+
+    SPIKE(1, "epp1", "epn1", new BigDecimal("1.20"), 4),
+    PEAK(2, "epp2", "epn2", null, 3),
+    FLAT(3, "epp3", "epn3", new BigDecimal("1.00"), 2),
+    VALLEY(4, "epp4", "epn4", new BigDecimal("0.50"), 1);
+
+    /** 账单明细 time_period:1尖 2峰 3平 4谷 */
+    private final int periodCode;
+    private final String forwardMetric;
+    private final String reverseMetric;
+    private final BigDecimal rateFactor;
+    private final int priority;
+
+    TimeSegmentEnum(int periodCode, String forwardMetric, String reverseMetric,
+                    BigDecimal rateFactor, int priority) {
+        this.periodCode = periodCode;
+        this.forwardMetric = forwardMetric;
+        this.reverseMetric = reverseMetric;
+        this.rateFactor = rateFactor;
+        this.priority = priority;
+    }
+
+    public int getPeriodCode() {
+        return periodCode;
+    }
+
+    public String getForwardMetric() {
+        return forwardMetric;
+    }
+
+    public String getReverseMetric() {
+        return reverseMetric;
+    }
+
+    public BigDecimal getRateFactor() {
+        return rateFactor;
+    }
+
+    public int getPriority() {
+        return priority;
+    }
+
+    public static TimeSegmentEnum fromPeriodCode(int periodCode) {
+        return Arrays.stream(values())
+                .filter(item -> item.periodCode == periodCode)
+                .findFirst()
+                .orElseThrow(() -> new IllegalArgumentException("未知时段编码:" + periodCode));
+    }
+}

+ 37 - 0
service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/job/VppSettlementMonthlyScheduler.java

@@ -0,0 +1,37 @@
+package com.usky.vpp.job;
+
+import com.usky.vpp.service.VppSettlementSyncService;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.scheduling.annotation.Scheduled;
+import org.springframework.stereotype.Component;
+
+/**
+ * 每月 1 号 00:35 同步上月电量核算与电费账单(电费金额为 0)
+ */
+@Component
+public class VppSettlementMonthlyScheduler {
+
+    private static final Logger log = LoggerFactory.getLogger(VppSettlementMonthlyScheduler.class);
+
+    @Value("${vpp.settlement.monthly-sync-enabled:true}")
+    private boolean enabled;
+
+    @Autowired
+    private VppSettlementSyncService settlementSyncService;
+
+    @Scheduled(cron = "${vpp.settlement.monthly-sync-cron:0 35 0 1 * ?}")
+    public void syncLastMonthSettlement() {
+        if (!enabled) {
+            return;
+        }
+        try {
+            int success = settlementSyncService.syncLastMonth();
+            log.info("月度电量/电费同步任务完成, successSites={}", success);
+        } catch (Exception ex) {
+            log.error("月度电量/电费同步任务失败: {}", ex.getMessage(), ex);
+        }
+    }
+}

+ 12 - 0
service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/mapper/VppDrSubsidyPredictionMapper.java

@@ -0,0 +1,12 @@
+package com.usky.vpp.mapper;
+
+import com.usky.common.mybatis.core.CrudMapper;
+import com.usky.vpp.domain.VppDrSubsidyPrediction;
+import org.springframework.stereotype.Repository;
+
+/**
+ * vpp_dr_subsidy_prediction Mapper
+ */
+@Repository
+public interface VppDrSubsidyPredictionMapper extends CrudMapper<VppDrSubsidyPrediction> {
+}

+ 29 - 0
service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/service/VppDrSubsidyPredictionService.java

@@ -0,0 +1,29 @@
+package com.usky.vpp.service;
+
+import com.usky.common.core.bean.CommonPage;
+import com.usky.vpp.service.vo.DrSubsidyPredictionVO;
+
+/**
+ * 补贴金额预测 业务接口
+ */
+public interface VppDrSubsidyPredictionService {
+
+    /**
+     * 分页查询补贴金额预测
+     *
+     * @param siteName   站点名称(模糊匹配)
+     * @param startTime  响应开始时间起始
+     * @param endTime    响应开始时间截止
+     * @param current    页码
+     * @param size       页大小
+     */
+    CommonPage<DrSubsidyPredictionVO> page(String siteName, String startTime, String endTime,
+                                           Integer current, Integer size);
+
+    /**
+     * 根据事件ID生成/更新补贴预测数据
+     *
+     * @param drEventId 需求响应事件ID
+     */
+    void generateByEventId(Long drEventId);
+}

+ 3 - 0
service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/service/VppSettlementService.java

@@ -16,6 +16,9 @@ public interface VppSettlementService {
         return null;
     }
 
+    /**
+     * 分页查询月度电量核算数据
+     */
     CommonPage<VppEnergyReadingMonthly> pageReading(Long id, String customerName, String calcCycle,
                                                     Integer current, Integer size);
 

+ 23 - 0
service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/service/VppSettlementSyncService.java

@@ -0,0 +1,23 @@
+package com.usky.vpp.service;
+
+/**
+ * 月度电量/电费核算数据同步
+ */
+public interface VppSettlementSyncService {
+
+    /**
+     * 同步上月(相对当前时间)各站点电量与电费账单(电费金额为 0)。
+     *
+     * @return 成功处理的站点数量
+     */
+    int syncLastMonth();
+
+    /**
+     * 同步指定结算年月的电量与电费账单(电费金额为 0)。
+     *
+     * @param settleYear  结算年,如 2026
+     * @param settleMonth 结算月,1-12
+     * @return 成功处理的站点数量
+     */
+    int syncMonth(int settleYear, int settleMonth);
+}

+ 9 - 4
service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/service/impl/VppContractServiceImpl.java

@@ -80,7 +80,8 @@ public class VppContractServiceImpl implements VppContractService {
         contract.setEffectiveDate(vo.getEffectiveDate());
         contract.setExpireDate(vo.getExpireDate());
         contract.setFileUrl(vo.getFileUrl());
-        contract.setShareRatio(vo.getShareRatio());
+        contract.setCustomerRatio(vo.getCustomerRatio());
+        contract.setOperatorRatio(vo.getOperatorRatio());
         contract.setPriceJson(VppFieldValidator.validateJson(vo.getPriceJson(), "价格定义"));
         contract.setAccountInfoJson(VppFieldValidator.validateJson(vo.getAccountInfoJson(), "账户信息"));
         contract.setRemark(vo.getRemark());
@@ -131,8 +132,11 @@ public class VppContractServiceImpl implements VppContractService {
         if (StringUtils.isNotBlank(vo.getFileUrl())) {
             existingRecord.setFileUrl(vo.getFileUrl());
         }
-        if (vo.getShareRatio() != null) {
-            existingRecord.setShareRatio(vo.getShareRatio());
+        if (vo.getCustomerRatio() != null) {
+            existingRecord.setCustomerRatio(vo.getCustomerRatio());
+        }
+        if (vo.getOperatorRatio() != null) {
+            existingRecord.setOperatorRatio(vo.getOperatorRatio());
         }
         if (vo.getPriceJson() != null) {
             existingRecord.setPriceJson(VppFieldValidator.validateJson(vo.getPriceJson(), "价格定义"));
@@ -207,7 +211,8 @@ public class VppContractServiceImpl implements VppContractService {
             vo.setEffectiveDate(entity.getEffectiveDate());
             vo.setExpireDate(entity.getExpireDate());
             vo.setFileUrl(entity.getFileUrl());
-            vo.setShareRatio(entity.getShareRatio());
+            vo.setCustomerRatio(entity.getCustomerRatio());
+            vo.setOperatorRatio(entity.getOperatorRatio());
             vo.setPriceJson(entity.getPriceJson());
             vo.setAccountInfoJson(entity.getAccountInfoJson());
             vo.setRemark(entity.getRemark());

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

@@ -84,6 +84,8 @@ public class VppDrServiceImpl implements VppDrService {
     private VppSiteResourceHelper siteResourceHelper;
     @Autowired
     private VppUnIntegrationService unIntegrationService;
+    @Autowired
+    private VppDrSubsidyPredictionServiceImpl subsidyPredictionService;
 
     @Override
     public CommonPage<VppDrEvent> pageEvent(Map<String, Object> params) {
@@ -420,6 +422,13 @@ public class VppDrServiceImpl implements VppDrService {
         }
 
         syncClearingToUn(eventId);
+
+        // ========== 出清完成后,自动生成补贴金额预测 ==========
+        try {
+            subsidyPredictionService.generateByEventId(eventId);
+        } catch (Exception ex) {
+            log.warn("补贴预测生成失败 eventId={}: {}", eventId, ex.getMessage());
+        }
     }
 
     private void syncClearingToUn(Long eventId) {
@@ -570,6 +579,13 @@ public class VppDrServiceImpl implements VppDrService {
         VppAuditHelper.fillUpdate(event);
         eventMapper.updateById(event);
 
+        // ========== 事件结束后,自动生成补贴金额预测 ==========
+        try {
+            subsidyPredictionService.generateByEventId(eventId);
+        } catch (Exception ex) {
+            log.warn("补贴预测生成失败 eventId={}: {}", eventId, ex.getMessage());
+        }
+
         VppDrEvaluation evaluation = evaluationMapper.selectOne(
                 new LambdaQueryWrapper<VppDrEvaluation>()
                         .eq(VppDrEvaluation::getEventId, eventId)

+ 212 - 0
service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/service/impl/VppDrSubsidyPredictionServiceImpl.java

@@ -0,0 +1,212 @@
+package com.usky.vpp.service.impl;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
+import com.usky.common.core.bean.CommonPage;
+import com.usky.common.core.exception.BusinessException;
+import com.usky.vpp.domain.VppDrEvent;
+import com.usky.vpp.domain.VppDrInvitation;
+import com.usky.vpp.domain.VppDrSubsidyPrediction;
+import com.usky.vpp.domain.VppSite;
+import com.usky.vpp.mapper.VppDrEventMapper;
+import com.usky.vpp.mapper.VppDrInvitationMapper;
+import com.usky.vpp.mapper.VppDrSubsidyPredictionMapper;
+import com.usky.vpp.mapper.VppSiteMapper;
+import com.usky.vpp.service.VppDrSubsidyPredictionService;
+import com.usky.vpp.service.vo.DrSubsidyPredictionVO;
+import com.usky.vpp.util.VppAuditHelper;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Propagation;
+import org.springframework.transaction.annotation.Transactional;
+import org.springframework.util.StringUtils;
+
+import java.math.BigDecimal;
+import java.math.RoundingMode;
+import java.time.Duration;
+import java.time.LocalDateTime;
+import java.util.Collections;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import java.util.Set;
+import java.util.function.Function;
+import java.util.stream.Collectors;
+
+/**
+ * 补贴金额预测 业务实现
+ */
+@Service
+public class VppDrSubsidyPredictionServiceImpl implements VppDrSubsidyPredictionService {
+
+    private static final Logger log = LoggerFactory.getLogger(VppDrSubsidyPredictionServiceImpl.class);
+
+    @Autowired
+    private VppDrSubsidyPredictionMapper subsidyPredictionMapper;
+    @Autowired
+    private VppDrInvitationMapper invitationMapper;
+    @Autowired
+    private VppDrEventMapper eventMapper;
+    @Autowired
+    private VppSiteMapper siteMapper;
+
+    @Override
+    public CommonPage<DrSubsidyPredictionVO> page(String siteName, String startTime, String endTime,
+                                                   Integer current, Integer size) {
+        Page<VppDrSubsidyPrediction> page = new Page<>(
+                current != null ? current : 1,
+                size != null && size <= 100 ? size : 20);
+
+        LambdaQueryWrapper<VppDrSubsidyPrediction> wrapper = new LambdaQueryWrapper<>();
+        if (StringUtils.hasText(siteName)) {
+            wrapper.like(VppDrSubsidyPrediction::getSiteName, siteName);
+        }
+        if (StringUtils.hasText(startTime)) {
+            wrapper.ge(VppDrSubsidyPrediction::getEventStartTime, startTime);
+        }
+        if (StringUtils.hasText(endTime)) {
+            wrapper.le(VppDrSubsidyPrediction::getEventStartTime, endTime);
+        }
+        wrapper.orderByDesc(VppDrSubsidyPrediction::getCreateTime);
+
+        Page<VppDrSubsidyPrediction> result = subsidyPredictionMapper.selectPage(page, wrapper);
+
+        List<DrSubsidyPredictionVO> list = result.getRecords().stream()
+                .map(this::toVo)
+                .collect(Collectors.toList());
+
+        return new CommonPage<>(list, result.getTotal(), size != null ? size : 20, current != null ? current : 1);
+    }
+
+    @Override
+    @Transactional(propagation = Propagation.REQUIRES_NEW, rollbackFor = Exception.class)
+    public void generateByEventId(Long drEventId) {
+        if (drEventId == null) {
+            throw new BusinessException("事件ID不能为空");
+        }
+
+        // 查询事件
+        VppDrEvent event = eventMapper.selectById(drEventId);
+        if (event == null) {
+            log.warn("补贴预测生成跳过:事件不存在 drEventId={}", drEventId);
+            return;
+        }
+
+        // 查询事件下所有邀约
+        List<VppDrInvitation> invitations = invitationMapper.selectList(
+                new LambdaQueryWrapper<VppDrInvitation>()
+                        .eq(VppDrInvitation::getDrEventId, drEventId)
+                        .eq(VppDrInvitation::getDeleteFlag, VppAuditHelper.NOT_DELETED)
+        );
+
+        if (invitations.isEmpty()) {
+            log.info("补贴预测生成跳过:事件下无邀约 drEventId={}", drEventId);
+            return;
+        }
+
+        // 批量查询站点名称
+        Set<Long> siteIds = invitations.stream()
+                .map(VppDrInvitation::getSite_id)
+                .filter(Objects::nonNull)
+                .collect(Collectors.toSet());
+        Map<Long, String> siteNameMap = siteIds.isEmpty() ? Collections.emptyMap()
+                : siteMapper.selectBatchIds(siteIds).stream()
+                        .collect(Collectors.toMap(VppSite::getId, VppSite::getSiteName, (a, b) -> a));
+
+        // 逐条计算并 upsert
+        for (VppDrInvitation invitation : invitations) {
+            VppDrSubsidyPrediction prediction = buildPrediction(event, invitation, siteNameMap);
+
+            // 按 invitation_id 唯一键,存在则更新,不存在则新增
+            VppDrSubsidyPrediction existing = subsidyPredictionMapper.selectOne(
+                    new LambdaQueryWrapper<VppDrSubsidyPrediction>()
+                            .eq(VppDrSubsidyPrediction::getInvitationId, invitation.getId())
+            );
+
+            if (existing != null) {
+                prediction.setId(existing.getId());
+                prediction.setUpdateTime(LocalDateTime.now());
+                subsidyPredictionMapper.updateById(prediction);
+            } else {
+                prediction.setCreateTime(LocalDateTime.now());
+                prediction.setUpdateTime(LocalDateTime.now());
+                subsidyPredictionMapper.insert(prediction);
+            }
+        }
+
+        log.info("补贴预测生成完成 drEventId={}, invitationCount={}", drEventId, invitations.size());
+    }
+
+    /**
+     * 构建单条补贴预测记录
+     */
+    private VppDrSubsidyPrediction buildPrediction(VppDrEvent event, VppDrInvitation invitation,
+                                                    Map<Long, String> siteNameMap) {
+        VppDrSubsidyPrediction p = new VppDrSubsidyPrediction();
+        p.setInvitationId(invitation.getId());
+        p.setDrEventId(event.getId());
+        p.setSiteId(invitation.getSite_id());
+        p.setSiteName(siteNameMap.getOrDefault(invitation.getSite_id(), null));
+        p.setEventStartTime(event.getStartTime());
+        p.setEventEndTime(event.getEndTime());
+
+        // 响应时长(分钟)
+        int durationMin = 0;
+        if (event.getStartTime() != null && event.getEndTime() != null
+                && event.getEndTime().isAfter(event.getStartTime())) {
+            durationMin = (int) Duration.between(event.getStartTime(), event.getEndTime()).toMinutes();
+        }
+        p.setResponseDurationMin(durationMin);
+
+        // 补贴标准
+        p.setSubsidyPrice(event.getSubsidyPrice());
+
+        // 预估响应容量
+        p.setEstimatedCapacityKw(invitation.getEstimated_response_capacity_kw());
+
+        // 实际出清
+        p.setClearedCapacityKw(invitation.getActual_cleared_capacity_kw());
+
+        // 预估完成率 = 预估容量 / 实际出清;实际出清为NULL或0时记为0
+        BigDecimal completionRate = BigDecimal.ZERO;
+        BigDecimal estimatedKw = invitation.getEstimated_response_capacity_kw();
+        BigDecimal actualKw = invitation.getActual_cleared_capacity_kw();
+        if (estimatedKw != null && actualKw != null && actualKw.compareTo(BigDecimal.ZERO) > 0) {
+            completionRate = estimatedKw.divide(actualKw, 4, RoundingMode.HALF_UP);
+        }
+        p.setCompletionRate(completionRate);
+
+        // 预估偏差率 = 1 - 预估完成率
+        BigDecimal deviationRate = BigDecimal.ONE.subtract(completionRate);
+        p.setDeviationRate(deviationRate);
+
+        // 预估补贴金额 = 补贴标准(为NULL时记为NULL)
+        p.setEstimatedSubsidyAmount(event.getSubsidyPrice());
+
+        p.setTenantId(event.getTenantId());
+        return p;
+    }
+
+    private DrSubsidyPredictionVO toVo(VppDrSubsidyPrediction entity) {
+        DrSubsidyPredictionVO vo = new DrSubsidyPredictionVO();
+        vo.setId(entity.getId());
+        vo.setInvitationId(entity.getInvitationId());
+        vo.setDrEventId(entity.getDrEventId());
+        vo.setSiteId(entity.getSiteId());
+        vo.setSiteName(entity.getSiteName());
+        vo.setEventStartTime(entity.getEventStartTime());
+        vo.setEventEndTime(entity.getEventEndTime());
+        vo.setResponseDurationMin(entity.getResponseDurationMin());
+        vo.setSubsidyPrice(entity.getSubsidyPrice());
+        vo.setEstimatedCapacityKw(entity.getEstimatedCapacityKw());
+        vo.setClearedCapacityKw(entity.getClearedCapacityKw());
+        vo.setCompletionRate(entity.getCompletionRate());
+        vo.setDeviationRate(entity.getDeviationRate());
+        vo.setEstimatedSubsidyAmount(entity.getEstimatedSubsidyAmount());
+        vo.setCreateTime(entity.getCreateTime());
+        vo.setUpdateTime(entity.getUpdateTime());
+        return vo;
+    }
+}

+ 382 - 0
service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/service/impl/VppSettlementSyncServiceImpl.java

@@ -0,0 +1,382 @@
+package com.usky.vpp.service.impl;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.usky.vpp.domain.VppDevice;
+import com.usky.vpp.domain.VppEnergyReadingMonthly;
+import com.usky.vpp.domain.VppSettlementBill;
+import com.usky.vpp.domain.VppSettlementBillDetail;
+import com.usky.vpp.domain.VppSite;
+import com.usky.vpp.enums.DeviceTypeMetricEnum;
+import com.usky.vpp.enums.TimeSegmentEnum;
+import com.usky.vpp.mapper.VppDeviceMapper;
+import com.usky.vpp.mapper.VppEnergyReadingMonthlyMapper;
+import com.usky.vpp.mapper.VppSettlementBillDetailMapper;
+import com.usky.vpp.mapper.VppSettlementBillMapper;
+import com.usky.vpp.mapper.VppSiteMapper;
+import com.usky.vpp.service.VppSettlementSyncService;
+import com.usky.vpp.service.VppTsdbQueryService;
+import com.usky.vpp.util.VppAuditHelper;
+import com.usky.vpp.util.VppEnergyUsageHelper;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.PlatformTransactionManager;
+import org.springframework.transaction.support.TransactionTemplate;
+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.YearMonth;
+import java.util.ArrayList;
+import java.util.EnumMap;
+import java.util.HashMap;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.TreeMap;
+
+/**
+ * 每月从时序库拉取站点分时电量,写入月度核算与电费账单(电费金额固定为 0)
+ */
+@Service
+public class VppSettlementSyncServiceImpl implements VppSettlementSyncService {
+
+    private static final Logger log = LoggerFactory.getLogger(VppSettlementSyncServiceImpl.class);
+
+    private static final int CALC_STATUS_PENDING = 0;
+    private static final int PAYMENT_STATUS_UNPAID = 0;
+    private static final int SCALE = 4;
+
+    @Autowired
+    private VppSiteMapper siteMapper;
+    @Autowired
+    private VppDeviceMapper deviceMapper;
+    @Autowired
+    private VppEnergyReadingMonthlyMapper energyReadingMonthlyMapper;
+    @Autowired
+    private VppSettlementBillMapper settlementBillMapper;
+    @Autowired
+    private VppSettlementBillDetailMapper settlementBillDetailMapper;
+    @Autowired
+    private VppTsdbQueryService vppTsdbQueryService;
+
+    private TransactionTemplate transactionTemplate;
+
+    @Autowired
+    public void setTransactionManager(PlatformTransactionManager transactionManager) {
+        this.transactionTemplate = new TransactionTemplate(transactionManager);
+    }
+
+    @Override
+    public int syncLastMonth() {
+        YearMonth lastMonth = YearMonth.now().minusMonths(1);
+        return syncMonth(lastMonth.getYear(), lastMonth.getMonthValue());
+    }
+
+    @Override
+    public int syncMonth(int settleYear, int settleMonth) {
+        YearMonth yearMonth = YearMonth.of(settleYear, settleMonth);
+        LocalDateTime startInclusive = yearMonth.atDay(1).atStartOfDay();
+        LocalDateTime endExclusive = yearMonth.plusMonths(1).atDay(1).atStartOfDay();
+        LocalDateTime queryEnd = endExclusive.minusSeconds(1);
+        String settleYearStr = String.valueOf(settleYear);
+        LocalDate dueDate = yearMonth.plusMonths(1).atEndOfMonth();
+
+        List<VppSite> sites = siteMapper.selectList(new LambdaQueryWrapper<VppSite>()
+                .eq(VppSite::getDeleteFlag, VppAuditHelper.NOT_DELETED));
+        if (CollectionUtils.isEmpty(sites)) {
+            log.info("月度结算同步:无可用站点, settle={}-{}", settleYear, settleMonth);
+            return 0;
+        }
+
+        int success = 0;
+        for (VppSite site : sites) {
+            try {
+                Boolean synced = transactionTemplate.execute(status ->
+                        syncSite(site, settleYearStr, settleMonth, startInclusive, endExclusive, queryEnd, dueDate));
+                if (Boolean.TRUE.equals(synced)) {
+                    success++;
+                }
+            } catch (Exception ex) {
+                log.error("月度结算同步失败 siteId={}, settle={}-{}, err={}",
+                        site.getId(), settleYear, settleMonth, ex.getMessage(), ex);
+            }
+        }
+        log.info("月度结算同步完成 settle={}-{}, sites={}, success={}",
+                settleYear, settleMonth, sites.size(), success);
+        return success;
+    }
+
+    private boolean syncSite(VppSite site,
+                             String settleYear,
+                             int settleMonth,
+                             LocalDateTime startInclusive,
+                             LocalDateTime endExclusive,
+                             LocalDateTime queryEnd,
+                             LocalDate dueDate) {
+        if (site == null || site.getId() == null) {
+            return false;
+        }
+        if (existsReading(site.getCustomerId(), site.getId(), settleYear, settleMonth)
+                || existsBill(site.getCustomerId(), site.getId(), settleYear, settleMonth)) {
+            log.info("月度结算已存在,跳过 siteId={}, settle={}-{}", site.getId(), settleYear, settleMonth);
+            return false;
+        }
+
+        SiteEnergyAggregate aggregate = querySiteEnergy(site.getId(), startInclusive, endExclusive, queryEnd);
+
+        VppEnergyReadingMonthly reading = buildReading(site, settleYear, settleMonth, aggregate);
+        energyReadingMonthlyMapper.insert(reading);
+
+        VppSettlementBill bill = buildBill(site, settleYear, settleMonth, reading.getTotalEnergyKwh(), dueDate);
+        settlementBillMapper.insert(bill);
+
+        for (VppSettlementBillDetail detail : buildBillDetails(bill.getId(), site.getTenantId(), aggregate)) {
+            settlementBillDetailMapper.insert(detail);
+        }
+        return true;
+    }
+
+    private SiteEnergyAggregate querySiteEnergy(Long siteId,
+                                                LocalDateTime startInclusive,
+                                                LocalDateTime endExclusive,
+                                                LocalDateTime queryEnd) {
+        List<VppDevice> devices = deviceMapper.selectList(new LambdaQueryWrapper<VppDevice>()
+                .eq(VppDevice::getSiteId, siteId)
+                .eq(VppDevice::getDeleteFlag, VppAuditHelper.NOT_DELETED));
+        if (CollectionUtils.isEmpty(devices)) {
+            return SiteEnergyAggregate.empty();
+        }
+
+        Map<DeviceTypeMetricEnum, List<String>> typeDeviceUuids = new HashMap<>();
+        for (VppDevice device : devices) {
+            if (device == null || !StringUtils.hasText(device.getDeviceUuid())) {
+                continue;
+            }
+            DeviceTypeMetricEnum.ofDeviceType(device.getDeviceType()).ifPresent(typeEnum ->
+                    typeDeviceUuids.computeIfAbsent(typeEnum, key -> new ArrayList<>())
+                            .add(device.getDeviceUuid().trim()));
+        }
+        if (typeDeviceUuids.isEmpty()) {
+            log.warn("站点无匹配电量设备类型 siteId={}", siteId);
+            return SiteEnergyAggregate.empty();
+        }
+
+        SiteEnergyAggregate aggregate = SiteEnergyAggregate.empty();
+        for (Map.Entry<DeviceTypeMetricEnum, List<String>> entry : typeDeviceUuids.entrySet()) {
+            DeviceTypeMetricEnum typeEnum = entry.getKey();
+            List<String> deviceUuids = new ArrayList<>(new LinkedHashSet<>(entry.getValue()));
+            Map<String, Map<String, TreeMap<LocalDateTime, BigDecimal>>> history =
+                    vppTsdbQueryService.queryDeviceMetricHistory(
+                            deviceUuids, startInclusive, queryEnd, typeEnum.getMetrics());
+            aggregate.merge(sumUsageBySegment(deviceUuids, history, startInclusive, endExclusive));
+        }
+        return aggregate;
+    }
+
+    private SiteEnergyAggregate sumUsageBySegment(List<String> deviceUuids,
+                                                  Map<String, Map<String, TreeMap<LocalDateTime, BigDecimal>>> history,
+                                                  LocalDateTime startInclusive,
+                                                  LocalDateTime endExclusive) {
+        SiteEnergyAggregate aggregate = SiteEnergyAggregate.empty();
+        if (CollectionUtils.isEmpty(deviceUuids) || history == null || history.isEmpty()) {
+            return aggregate;
+        }
+
+        Map<TimeSegmentEnum, BigDecimal> forward = new EnumMap<>(TimeSegmentEnum.class);
+        Map<TimeSegmentEnum, BigDecimal> reverse = new EnumMap<>(TimeSegmentEnum.class);
+        for (TimeSegmentEnum segment : TimeSegmentEnum.values()) {
+            forward.put(segment, BigDecimal.ZERO);
+            reverse.put(segment, BigDecimal.ZERO);
+        }
+        BigDecimal totalForward = BigDecimal.ZERO;
+        BigDecimal totalReverse = BigDecimal.ZERO;
+        boolean hasData = false;
+
+        for (String deviceUuid : deviceUuids) {
+            Map<String, TreeMap<LocalDateTime, BigDecimal>> metricMap = history.get(deviceUuid);
+            if (metricMap == null || metricMap.isEmpty()) {
+                continue;
+            }
+            for (TimeSegmentEnum segment : TimeSegmentEnum.values()) {
+                BigDecimal fwd = VppEnergyUsageHelper.calcUsage(
+                        metricMap.get(segment.getForwardMetric()), startInclusive, endExclusive);
+                if (fwd != null) {
+                    hasData = true;
+                    forward.put(segment, forward.get(segment).add(fwd));
+                }
+                BigDecimal rev = VppEnergyUsageHelper.calcUsage(
+                        metricMap.get(segment.getReverseMetric()), startInclusive, endExclusive);
+                if (rev != null) {
+                    hasData = true;
+                    reverse.put(segment, reverse.get(segment).add(rev));
+                }
+            }
+            BigDecimal epp = VppEnergyUsageHelper.calcUsage(metricMap.get("epp"), startInclusive, endExclusive);
+            if (epp != null) {
+                hasData = true;
+                totalForward = totalForward.add(epp);
+            }
+            BigDecimal epn = VppEnergyUsageHelper.calcUsage(metricMap.get("epn"), startInclusive, endExclusive);
+            if (epn != null) {
+                hasData = true;
+                totalReverse = totalReverse.add(epn);
+            }
+        }
+
+        BigDecimal sharp = scale(forward.get(TimeSegmentEnum.SPIKE));
+        BigDecimal peak = scale(forward.get(TimeSegmentEnum.PEAK));
+        BigDecimal flat = scale(forward.get(TimeSegmentEnum.FLAT));
+        BigDecimal valley = scale(forward.get(TimeSegmentEnum.VALLEY));
+        BigDecimal segmentSum = sharp.add(peak).add(flat).add(valley);
+
+        BigDecimal totalEnergy = totalForward.compareTo(BigDecimal.ZERO) > 0 ? scale(totalForward) : segmentSum;
+        BigDecimal reverseSum = reverse.values().stream().reduce(BigDecimal.ZERO, BigDecimal::add);
+        BigDecimal genEnergy = totalReverse.compareTo(BigDecimal.ZERO) > 0 ? scale(totalReverse) : scale(reverseSum);
+
+        aggregate.hasData = hasData;
+        aggregate.sharpEnergyKwh = sharp;
+        aggregate.peakEnergyKwh = peak;
+        aggregate.flatEnergyKwh = flat;
+        aggregate.valleyEnergyKwh = valley;
+        aggregate.totalEnergyKwh = totalEnergy;
+        aggregate.genEnergyKwh = genEnergy;
+        return aggregate;
+    }
+
+    private VppEnergyReadingMonthly buildReading(VppSite site,
+                                                 String settleYear,
+                                                 int settleMonth,
+                                                 SiteEnergyAggregate aggregate) {
+        VppEnergyReadingMonthly reading = new VppEnergyReadingMonthly();
+        reading.setCustomerId(site.getCustomerId());
+        reading.setSiteId(site.getId());
+        reading.setSettleYear(settleYear);
+        reading.setSettleMonth(settleMonth);
+        reading.setSharpEnergyKwh(aggregate.sharpEnergyKwh);
+        reading.setPeakEnergyKwh(aggregate.peakEnergyKwh);
+        reading.setFlatEnergyKwh(aggregate.flatEnergyKwh);
+        reading.setValleyEnergyKwh(aggregate.valleyEnergyKwh);
+        reading.setTotalEnergyKwh(aggregate.totalEnergyKwh);
+        reading.setGenEnergyKwh(aggregate.genEnergyKwh);
+        reading.setCalcStatus(CALC_STATUS_PENDING);
+        reading.setCalcAt(null);
+        reading.setTenantId(site.getTenantId());
+        VppAuditHelper.fillCreate(reading);
+        if (reading.getTenantId() == null) {
+            reading.setTenantId(site.getTenantId());
+        }
+        return reading;
+    }
+
+    private VppSettlementBill buildBill(VppSite site,
+                                        String settleYear,
+                                        int settleMonth,
+                                        BigDecimal totalEnergyKwh,
+                                        LocalDate dueDate) {
+        VppSettlementBill bill = new VppSettlementBill();
+        bill.setBillNo(VppAuditHelper.nextBillNo());
+        bill.setCustomerId(site.getCustomerId());
+        bill.setSiteId(site.getId());
+        bill.setSettleYear(settleYear);
+        bill.setSettleMonth(settleMonth);
+        bill.setTotalEnergyKwh(VppEnergyUsageHelper.zeroIfNull(totalEnergyKwh));
+        // 当前阶段电费统一为 0
+        bill.setTotalAmount(BigDecimal.ZERO.setScale(SCALE, RoundingMode.HALF_UP));
+        bill.setPaidAmount(BigDecimal.ZERO.setScale(SCALE, RoundingMode.HALF_UP));
+        bill.setPaymentStatus(PAYMENT_STATUS_UNPAID);
+        bill.setDueDate(dueDate);
+        bill.setGeneratedAt(LocalDateTime.now());
+        bill.setTenantId(site.getTenantId());
+        VppAuditHelper.fillCreate(bill);
+        if (bill.getTenantId() == null) {
+            bill.setTenantId(site.getTenantId());
+        }
+        return bill;
+    }
+
+    private List<VppSettlementBillDetail> buildBillDetails(Long billId,
+                                                           Integer tenantId,
+                                                           SiteEnergyAggregate aggregate) {
+        List<VppSettlementBillDetail> details = new ArrayList<>(4);
+        details.add(buildDetail(billId, tenantId, TimeSegmentEnum.SPIKE, aggregate.sharpEnergyKwh));
+        details.add(buildDetail(billId, tenantId, TimeSegmentEnum.PEAK, aggregate.peakEnergyKwh));
+        details.add(buildDetail(billId, tenantId, TimeSegmentEnum.FLAT, aggregate.flatEnergyKwh));
+        details.add(buildDetail(billId, tenantId, TimeSegmentEnum.VALLEY, aggregate.valleyEnergyKwh));
+        return details;
+    }
+
+    private VppSettlementBillDetail buildDetail(Long billId,
+                                                Integer tenantId,
+                                                TimeSegmentEnum segment,
+                                                BigDecimal energyKwh) {
+        VppSettlementBillDetail detail = new VppSettlementBillDetail();
+        detail.setBillId(billId);
+        detail.setTimePeriod(segment.getPeriodCode());
+        detail.setEnergyKwh(VppEnergyUsageHelper.zeroIfNull(energyKwh));
+        detail.setPrice(BigDecimal.ZERO.setScale(SCALE, RoundingMode.HALF_UP));
+        detail.setAmount(BigDecimal.ZERO.setScale(SCALE, RoundingMode.HALF_UP));
+        detail.setTenantId(tenantId);
+        return detail;
+    }
+
+    private boolean existsReading(Long customerId, Long siteId, String settleYear, int settleMonth) {
+        return energyReadingMonthlyMapper.selectCount(new LambdaQueryWrapper<VppEnergyReadingMonthly>()
+                .eq(VppEnergyReadingMonthly::getCustomerId, customerId)
+                .eq(VppEnergyReadingMonthly::getSiteId, siteId)
+                .eq(VppEnergyReadingMonthly::getSettleYear, settleYear)
+                .eq(VppEnergyReadingMonthly::getSettleMonth, settleMonth)
+                .eq(VppEnergyReadingMonthly::getDeleteFlag, VppAuditHelper.NOT_DELETED)) > 0;
+    }
+
+    private boolean existsBill(Long customerId, Long siteId, String settleYear, int settleMonth) {
+        return settlementBillMapper.selectCount(new LambdaQueryWrapper<VppSettlementBill>()
+                .eq(VppSettlementBill::getCustomerId, customerId)
+                .eq(VppSettlementBill::getSiteId, siteId)
+                .eq(VppSettlementBill::getSettleYear, settleYear)
+                .eq(VppSettlementBill::getSettleMonth, settleMonth)
+                .eq(VppSettlementBill::getDeleteFlag, VppAuditHelper.NOT_DELETED)) > 0;
+    }
+
+    private static BigDecimal scale(BigDecimal value) {
+        return VppEnergyUsageHelper.zeroIfNull(value).setScale(SCALE, RoundingMode.HALF_UP);
+    }
+
+    private static final class SiteEnergyAggregate {
+        private boolean hasData;
+        private BigDecimal sharpEnergyKwh = BigDecimal.ZERO;
+        private BigDecimal peakEnergyKwh = BigDecimal.ZERO;
+        private BigDecimal flatEnergyKwh = BigDecimal.ZERO;
+        private BigDecimal valleyEnergyKwh = BigDecimal.ZERO;
+        private BigDecimal totalEnergyKwh = BigDecimal.ZERO;
+        private BigDecimal genEnergyKwh = BigDecimal.ZERO;
+
+        static SiteEnergyAggregate empty() {
+            SiteEnergyAggregate aggregate = new SiteEnergyAggregate();
+            aggregate.sharpEnergyKwh = BigDecimal.ZERO.setScale(SCALE, RoundingMode.HALF_UP);
+            aggregate.peakEnergyKwh = BigDecimal.ZERO.setScale(SCALE, RoundingMode.HALF_UP);
+            aggregate.flatEnergyKwh = BigDecimal.ZERO.setScale(SCALE, RoundingMode.HALF_UP);
+            aggregate.valleyEnergyKwh = BigDecimal.ZERO.setScale(SCALE, RoundingMode.HALF_UP);
+            aggregate.totalEnergyKwh = BigDecimal.ZERO.setScale(SCALE, RoundingMode.HALF_UP);
+            aggregate.genEnergyKwh = BigDecimal.ZERO.setScale(SCALE, RoundingMode.HALF_UP);
+            return aggregate;
+        }
+
+        void merge(SiteEnergyAggregate other) {
+            if (other == null) {
+                return;
+            }
+            this.hasData = this.hasData || other.hasData;
+            this.sharpEnergyKwh = this.sharpEnergyKwh.add(other.sharpEnergyKwh);
+            this.peakEnergyKwh = this.peakEnergyKwh.add(other.peakEnergyKwh);
+            this.flatEnergyKwh = this.flatEnergyKwh.add(other.flatEnergyKwh);
+            this.valleyEnergyKwh = this.valleyEnergyKwh.add(other.valleyEnergyKwh);
+            this.totalEnergyKwh = this.totalEnergyKwh.add(other.totalEnergyKwh);
+            this.genEnergyKwh = this.genEnergyKwh.add(other.genEnergyKwh);
+        }
+    }
+}

+ 90 - 0
service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/service/vo/DrSubsidyPredictionVO.java

@@ -0,0 +1,90 @@
+package com.usky.vpp.service.vo;
+
+import lombok.Data;
+
+import java.math.BigDecimal;
+import java.time.LocalDateTime;
+
+/**
+ * 补贴金额预测 响应VO
+ */
+@Data
+public class DrSubsidyPredictionVO {
+
+    private Long id;
+
+    /**
+     * 邀约ID
+     */
+    private Long invitationId;
+
+    /**
+     * 需求响应事件ID
+     */
+    private Long drEventId;
+
+    /**
+     * 站点ID
+     */
+    private Long siteId;
+
+    /**
+     * 站点名称
+     */
+    private String siteName;
+
+    /**
+     * 响应开始时间
+     */
+    private LocalDateTime eventStartTime;
+
+    /**
+     * 响应结束时间
+     */
+    private LocalDateTime eventEndTime;
+
+    /**
+     * 响应时长(分钟)
+     */
+    private Integer responseDurationMin;
+
+    /**
+     * 补贴标准(元/kWh)
+     */
+    private BigDecimal subsidyPrice;
+
+    /**
+     * 预估响应容量(kW)
+     */
+    private BigDecimal estimatedCapacityKw;
+
+    /**
+     * 实际出清(kW)
+     */
+    private BigDecimal clearedCapacityKw;
+
+    /**
+     * 预估完成率(0~1)
+     */
+    private BigDecimal completionRate;
+
+    /**
+     * 预估偏差率(0~1)
+     */
+    private BigDecimal deviationRate;
+
+    /**
+     * 预估补贴金额
+     */
+    private BigDecimal estimatedSubsidyAmount;
+
+    /**
+     * 创建时间
+     */
+    private LocalDateTime createTime;
+
+    /**
+     * 更新时间
+     */
+    private LocalDateTime updateTime;
+}

+ 7 - 2
service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/service/vo/VppContractRequestVO.java

@@ -65,9 +65,14 @@ public class VppContractRequestVO {
     private String fileUrl;
 
     /**
-     * 分成比例
+     * 客户比例
      */
-    private BigDecimal shareRatio;
+    private BigDecimal customerRatio;
+
+    /**
+     * 运营商比例
+     */
+    private BigDecimal operatorRatio;
 
     /**
      * 价格定义(JSON)

+ 7 - 2
service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/service/vo/VppContractResponseVO.java

@@ -78,9 +78,14 @@ public class VppContractResponseVO {
     private String fileUrl;
 
     /**
-     * 分成比例
+     * 客户比例
      */
-    private BigDecimal shareRatio;
+    private BigDecimal customerRatio;
+
+    /**
+     * 运营商比例
+     */
+    private BigDecimal operatorRatio;
 
     /**
      * 价格定义(JSON)

+ 59 - 0
service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/util/ElectricFeeCalcUtil.java

@@ -0,0 +1,59 @@
+package com.usky.vpp.util;
+
+import com.usky.vpp.enums.ElectricSeasonEnum;
+import com.usky.vpp.enums.TimeSegmentEnum;
+
+import java.math.BigDecimal;
+import java.math.RoundingMode;
+
+/**
+ * 分时电费计算(平段基准价 × 季节浮动系数 × 电量)
+ */
+public final class ElectricFeeCalcUtil {
+
+    private static final BigDecimal PEAK_RATE_SUMMER_WINTER = new BigDecimal("1.50");
+    private static final BigDecimal PEAK_RATE_NORMAL = new BigDecimal("1.30");
+    private static final int SCALE = 4;
+
+    private ElectricFeeCalcUtil() {
+    }
+
+    public static BigDecimal calcSegmentFee(BigDecimal flatUnitPrice,
+                                            ElectricSeasonEnum season,
+                                            TimeSegmentEnum segment,
+                                            BigDecimal useKwh) {
+        if (flatUnitPrice == null || useKwh == null || useKwh.compareTo(BigDecimal.ZERO) <= 0) {
+            return BigDecimal.ZERO.setScale(SCALE, RoundingMode.HALF_UP);
+        }
+        BigDecimal segUnitPrice = resolveSegmentUnitPrice(flatUnitPrice, season, segment);
+        return segUnitPrice.multiply(useKwh).setScale(SCALE, RoundingMode.HALF_UP);
+    }
+
+    public static BigDecimal resolveSegmentUnitPrice(BigDecimal flatUnitPrice,
+                                                     ElectricSeasonEnum season,
+                                                     TimeSegmentEnum segment) {
+        if (flatUnitPrice == null || segment == null) {
+            return BigDecimal.ZERO;
+        }
+        switch (segment) {
+            case FLAT:
+                return flatUnitPrice;
+            case VALLEY:
+                return flatUnitPrice.multiply(TimeSegmentEnum.VALLEY.getRateFactor());
+            case PEAK:
+                return flatUnitPrice.multiply(peakRate(season));
+            case SPIKE:
+                return flatUnitPrice.multiply(peakRate(season))
+                        .multiply(TimeSegmentEnum.SPIKE.getRateFactor());
+            default:
+                throw new IllegalArgumentException("未知时段");
+        }
+    }
+
+    private static BigDecimal peakRate(ElectricSeasonEnum season) {
+        if (ElectricSeasonEnum.SUMMER == season || ElectricSeasonEnum.WINTER_SPIKE == season) {
+            return PEAK_RATE_SUMMER_WINTER;
+        }
+        return PEAK_RATE_NORMAL;
+    }
+}

+ 65 - 0
service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/util/ElectricTimeUtil.java

@@ -0,0 +1,65 @@
+package com.usky.vpp.util;
+
+import com.usky.vpp.enums.ElectricSeasonEnum;
+import com.usky.vpp.enums.ElectricUserTypeEnum;
+import com.usky.vpp.enums.TimeSegmentEnum;
+
+import java.time.LocalDateTime;
+
+/**
+ * 分时电价工具:根据时间、用户类型判断当前属于哪个时段
+ */
+public final class ElectricTimeUtil {
+
+    private ElectricTimeUtil() {
+    }
+
+    public static TimeSegmentEnum getCurrentSegment(LocalDateTime dateTime, ElectricUserTypeEnum userType) {
+        int month = dateTime.getMonthValue();
+        int hour = dateTime.getHour();
+        ElectricSeasonEnum season = ElectricSeasonEnum.getByMonth(month);
+
+        if (ElectricUserTypeEnum.SINGLE_PART == userType) {
+            return hour >= 6 && hour < 22 ? TimeSegmentEnum.PEAK : TimeSegmentEnum.VALLEY;
+        }
+
+        if (isSpikeHour(hour, season)) {
+            return TimeSegmentEnum.SPIKE;
+        }
+        if (isPeakHour(hour, season)) {
+            return TimeSegmentEnum.PEAK;
+        }
+        if (isFlatHour(hour, season)) {
+            return TimeSegmentEnum.FLAT;
+        }
+        return TimeSegmentEnum.VALLEY;
+    }
+
+    private static boolean isSpikeHour(int hour, ElectricSeasonEnum season) {
+        if (ElectricSeasonEnum.SUMMER == season) {
+            return hour >= 12 && hour < 14;
+        }
+        if (ElectricSeasonEnum.WINTER_SPIKE == season) {
+            return hour >= 19 && hour < 21;
+        }
+        return false;
+    }
+
+    private static boolean isPeakHour(int hour, ElectricSeasonEnum season) {
+        if (ElectricSeasonEnum.SUMMER == season) {
+            return (hour >= 8 && hour < 15) || (hour >= 18 && hour < 21);
+        }
+        return (hour >= 8 && hour < 11) || (hour >= 18 && hour < 21);
+    }
+
+    private static boolean isFlatHour(int hour, ElectricSeasonEnum season) {
+        if (ElectricSeasonEnum.SUMMER == season) {
+            return (hour >= 6 && hour < 8) || (hour >= 15 && hour < 18) || (hour >= 21 && hour < 22);
+        }
+        return (hour >= 6 && hour < 8) || (hour >= 11 && hour < 18) || (hour >= 21 && hour < 22);
+    }
+
+    public static boolean isValleyHour(int hour) {
+        return hour >= 22 || hour < 6;
+    }
+}

+ 5 - 0
service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/util/VppAuditHelper.java

@@ -79,6 +79,11 @@ public final class VppAuditHelper {
                 + UUID.randomUUID().toString().substring(0, 4).toUpperCase();
     }
 
+    public static String nextBillNo() {
+        return "BILL_" + DateTimeFormatter.ofPattern("yyyyMMddHHmmss").format(LocalDateTime.now())
+                + UUID.randomUUID().toString().substring(0, 4).toUpperCase();
+    }
+
     private static String currentUsername() {
         try {
             return SecurityUtils.getUsername();

+ 59 - 0
service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/util/VppEnergyUsageHelper.java

@@ -0,0 +1,59 @@
+package com.usky.vpp.util;
+
+import java.math.BigDecimal;
+import java.math.RoundingMode;
+import java.time.LocalDateTime;
+import java.util.Map;
+import java.util.TreeMap;
+
+/**
+ * 累计电能表时序差值计算(与 EMS 分段用量逻辑一致)
+ */
+public final class VppEnergyUsageHelper {
+
+    private static final int SCALE = 4;
+
+    private VppEnergyUsageHelper() {
+    }
+
+    /**
+     * 计算区间内累计电能增量:末值 - 首值;若回零则取末值。
+     *
+     * @return 无有效读数时返回 null
+     */
+    public static BigDecimal calcUsage(TreeMap<LocalDateTime, BigDecimal> timeValueMap,
+                                       LocalDateTime startInclusive,
+                                       LocalDateTime endExclusive) {
+        if (timeValueMap == null || timeValueMap.isEmpty() || startInclusive == null || endExclusive == null) {
+            return null;
+        }
+        TreeMap<LocalDateTime, BigDecimal> segment = new TreeMap<>();
+        for (Map.Entry<LocalDateTime, BigDecimal> entry : timeValueMap.entrySet()) {
+            LocalDateTime ts = entry.getKey();
+            if (!ts.isBefore(startInclusive) && ts.isBefore(endExclusive)) {
+                segment.put(ts, entry.getValue());
+            }
+        }
+        if (segment.size() < 2) {
+            return null;
+        }
+        BigDecimal first = segment.firstEntry().getValue();
+        BigDecimal last = segment.lastEntry().getValue();
+        if (first == null || last == null) {
+            return null;
+        }
+        BigDecimal usage = last.subtract(first);
+        if (usage.compareTo(BigDecimal.ZERO) < 0) {
+            usage = last;
+        }
+        return usage.setScale(SCALE, RoundingMode.HALF_UP);
+    }
+
+    public static BigDecimal zeroIfNull(BigDecimal value) {
+        return value == null ? BigDecimal.ZERO.setScale(SCALE, RoundingMode.HALF_UP) : value;
+    }
+
+    public static BigDecimal addNullable(BigDecimal left, BigDecimal right) {
+        return zeroIfNull(left).add(zeroIfNull(right));
+    }
+}

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

@@ -38,3 +38,7 @@ vpp:
     invitation-template-code: SMS_465362899
   report:
     retry-max: 3
+  # 月度电量/电费核算同步(每月1号00:35拉上月数据)
+  settlement:
+    monthly-sync-enabled: true
+    monthly-sync-cron: "0 35 0 1 * ?"